From a40443048d60d74e75c45e4e9e1374f8a0f50869 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sat, 25 Jul 2026 19:18:28 +0000 Subject: [PATCH 01/16] implement hashtag blocks --- migrations/Version20260725164132.php | 35 +++++ src/Entity/HashtagBlock.php | 49 +++++++ src/Entity/User.php | 41 +++++- src/Event/HashtagBlockChangedEvent.php | 16 +++ .../Hashtag/HashtagBlockSubscriber.php | 26 ++++ src/Repository/ContentRepository.php | 40 ++++-- src/Repository/Criteria.php | 4 + src/Repository/EntryCommentRepository.php | 26 ++-- src/Repository/PostCommentRepository.php | 21 ++- src/Service/TagManager.php | 18 +++ src/Utils/SqlHelpers.php | 56 ++++++-- tests/FactoryTrait.php | 23 +++ .../Service/Hashtag/TagBlockTest.php | 132 ++++++++++++++++++ tests/WebTestCase.php | 11 ++ 14 files changed, 458 insertions(+), 40 deletions(-) create mode 100644 migrations/Version20260725164132.php create mode 100644 src/Entity/HashtagBlock.php create mode 100644 src/Event/HashtagBlockChangedEvent.php create mode 100644 src/EventSubscriber/Hashtag/HashtagBlockSubscriber.php create mode 100644 tests/Functional/Service/Hashtag/TagBlockTest.php diff --git a/migrations/Version20260725164132.php b/migrations/Version20260725164132.php new file mode 100644 index 0000000000..cc3821ce41 --- /dev/null +++ b/migrations/Version20260725164132.php @@ -0,0 +1,35 @@ +addSql('CREATE SEQUENCE hashtag_block_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE hashtag_block (id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, user_id INT NOT NULL, hashtag_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_A7D852AA76ED395 ON hashtag_block (user_id)'); + $this->addSql('CREATE INDEX IDX_A7D852AFB34EF56 ON hashtag_block (hashtag_id)'); + $this->addSql('CREATE UNIQUE INDEX hashtag_block_idx ON hashtag_block (user_id, hashtag_id)'); + $this->addSql('ALTER TABLE hashtag_block ADD CONSTRAINT FK_A7D852AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE hashtag_block ADD CONSTRAINT FK_A7D852AFB34EF56 FOREIGN KEY (hashtag_id) REFERENCES hashtag (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE hashtag_block_id_seq CASCADE'); + $this->addSql('ALTER TABLE hashtag_block DROP CONSTRAINT FK_A7D852AA76ED395'); + $this->addSql('ALTER TABLE hashtag_block DROP CONSTRAINT FK_A7D852AFB34EF56'); + $this->addSql('DROP TABLE hashtag_block'); + } +} diff --git a/src/Entity/HashtagBlock.php b/src/Entity/HashtagBlock.php new file mode 100644 index 0000000000..9f4feb98ca --- /dev/null +++ b/src/Entity/HashtagBlock.php @@ -0,0 +1,49 @@ +createdAtTraitConstruct(); + + $this->user = $user; + $this->hashtag = $hashtag; + } + + public function getId(): ?int + { + return $this->id; + } +} \ No newline at end of file diff --git a/src/Entity/User.php b/src/Entity/User.php index 73f187959e..a12782b5d6 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -232,6 +232,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil #[OneToMany(mappedBy: 'user', targetEntity: DomainBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $blockedDomains; + #[OneToMany(mappedBy: 'user', targetEntity: HashtagBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[OrderBy(['createdAt' => 'DESC'])] + public Collection $blockedHashtags; #[OneToMany(mappedBy: 'reporting', targetEntity: Report::class, cascade: ['persist'], fetch: 'EXTRA_LAZY')] #[OrderBy(['createdAt' => 'DESC'])] public Collection $reports; @@ -313,6 +316,7 @@ public function __construct( $this->blockers = new ArrayCollection(); $this->blockedMagazines = new ArrayCollection(); $this->blockedDomains = new ArrayCollection(); + $this->blockedHashtags = new ArrayCollection(); $this->reports = new ArrayCollection(); $this->favourites = new ArrayCollection(); $this->violations = new ArrayCollection(); @@ -671,7 +675,42 @@ public function unblockDomain(Domain $domain): void if ($this->blockedDomains->removeElement($domainBlock)) { if ($domainBlock->user === $this) { $domainBlock->domain = null; - $this->blockedMagazines->removeElement($domainBlock); + $this->blockedDomains->removeElement($domainBlock); + } + } + } + + public function blockHashtag(Hashtag $hashtag): self + { + if (!$this->isBlockedHashtag($hashtag)) { + $this->blockedHashtags->add(new HashtagBlock($this, $hashtag)); + } + + return $this; + } + + public function isBlockedHashtag(Hashtag $hashtag): bool + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('hashtag', $hashtag)); + + return $this->blockedHashtags->matching($criteria)->count() > 0; + } + + public function unblockHashtag(Hashtag $hashtag): void + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('hashtag', $hashtag)); + + /** + * @var HashtagBlock $hashtagBlock + */ + $hashtagBlock = $this->blockedHashtags->matching($criteria)->first(); + + if ($this->blockedHashtags->removeElement($hashtagBlock)) { + if ($hashtagBlock->user === $this) { + $hashtagBlock->hashtag = null; + $this->blockedHashtags->removeElement($hashtagBlock); } } } diff --git a/src/Event/HashtagBlockChangedEvent.php b/src/Event/HashtagBlockChangedEvent.php new file mode 100644 index 0000000000..d571993d9a --- /dev/null +++ b/src/Event/HashtagBlockChangedEvent.php @@ -0,0 +1,16 @@ + 'handleHashtagBlockChangedEvent']; + } + + public function handleHashtagBlockChangedEvent(HashtagBlockChangedEvent $event): void + { + $this->sqlHelpers->clearCachedUserHashtagBlocks($event->user); + } +} \ No newline at end of file diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index cb32ab31ed..e150184b13 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -37,9 +37,9 @@ public function __construct( ) { } - public function findByCriteria(Criteria $criteria): PagerfantaInterface + public function findByCriteria(Criteria $criteria, ?User $loggedInUser = null): PagerfantaInterface { - $query = $this->getQueryAndParameters($criteria, false); + $query = $this->getQueryAndParameters($criteria, false, $loggedInUser); $conn = $this->entityManager->getConnection(); $numResults = null; @@ -63,9 +63,9 @@ public function findByCriteria(Criteria $criteria): PagerfantaInterface * * @throws Exception */ - public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, mixed $currentCursor2 = null): CursorPaginationInterface + public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, mixed $currentCursor2 = null, ?User $loggedInUser = null): CursorPaginationInterface { - $query = $this->getQueryAndParameters($criteria, true); + $query = $this->getQueryAndParameters($criteria, true, $loggedInUser); $conn = $this->entityManager->getConnection(); $orderings = $this->getOrderings($criteria); $start = new \DateTimeImmutable(); @@ -99,11 +99,12 @@ public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, /** * @return array{sql: string, parameters: array}> */ - private function getQueryAndParameters(Criteria $criteria, bool $addCursor): array + private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?User $user): array { $includeEntries = Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_THREADS === $criteria->content; + $includePosts = Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_MICROBLOG === $criteria->content; $includeEntryComments = Criteria::CONTENT_COMBINED === $criteria->content && $criteria->includeBoosts; - $includePostComments = (Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_MICROBLOG === $criteria->content) && $criteria->includeBoosts; + $includePostComments = $includePosts && $criteria->includeBoosts; $parameters = [ 'visible' => VisibilityInterface::VISIBILITY_VISIBLE, @@ -111,7 +112,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr ]; /** @var ?User $user */ - $user = $this->security->getUser(); + $user = $user ?? $this->security->getUser(); $currenFilterLists = $user?->getCurrentFilterLists() ?? []; $parameters['loggedInUser'] = $user?->getId(); @@ -271,6 +272,8 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $blockingClausePost = ''; $blockingClauseEntry = ''; + $blockingClausePostComment = ''; + $blockingClauseEntryComment = ''; if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { if (null === $criteria->cachedUserBlocks) { $blockingClausePost = 'NOT EXISTS (SELECT * FROM user_block ub WHERE ub.blocker_id = :loggedInUser AND ub.blocked_id = c.user_id)'; @@ -296,6 +299,23 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $parameters['cachedUserBlockedDomains'] = $criteria->cachedUserBlockedDomains; } } + + $blockingClauseEntryComment = $blockingClausePost; + $blockingClausePostComment = $blockingClausePost; + + if(null == $criteria->cachedUserBlockedHashtags) { + $blockingClauseEntry = $blockingClauseEntry.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClausePost = $blockingClausePost.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClauseEntryComment = $blockingClauseEntryComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_comment_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClausePostComment = $blockingClausePostComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_comment_id = c.id AND hb.user_id = :loggedInUser)'; + } else { + $blockingClauseEntry = $blockingClauseEntry.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClausePost = $blockingClausePost.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClauseEntryComment = $blockingClauseEntryComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClausePostComment = $blockingClausePostComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + + $parameters['cachedUserBlockedHashtags'] = $criteria->cachedUserBlockedHashtags; + } } $hideAdultClause = ''; @@ -417,7 +437,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $subClauseEntryComment, $modClause, $favClauseEntryComment, - $blockingClausePost, + $blockingClauseEntryComment, $hideAdultClause, $visibilityClauseM, $visibilityClauseC, @@ -439,7 +459,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $subClausePostComment, $modClause, $favClausePostComment, - $blockingClausePost, + $blockingClausePostComment, $hideAdultClause, $visibilityClauseM, $visibilityClauseC, @@ -489,7 +509,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr } else { $innerSql = "$postSql $orderBy $innerLimit"; } - } else { + } else { // Criteria::CONTENT_COMBINED $innerSql = "($entrySql $orderBy $innerLimit) UNION ALL ($postSql $orderBy $innerLimit)"; if ($includeEntryComments) { $innerSql .= " UNION ALL ($entryCommentSql $orderBy $innerLimit)"; diff --git a/src/Repository/Criteria.php b/src/Repository/Criteria.php index f5621701e2..d0edbecdd9 100644 --- a/src/Repository/Criteria.php +++ b/src/Repository/Criteria.php @@ -121,6 +121,9 @@ abstract class Criteria /** @var int[]|null */ public ?array $cachedUserBlockedDomains = null; + /** @var int[]|null */ + public ?array $cachedUserBlockedHashtags = null; + public const THEME_MBIN = 'mbin'; public const THEME_KBIN = 'kbin'; public const THEME_AUTO = 'default'; @@ -359,6 +362,7 @@ public function fetchCachedItems(SqlHelpers $sqlHelpers, User $loggedInUser): vo $this->cachedUserBlocks = $sqlHelpers->getCachedUserBlocks($loggedInUser); $this->cachedUserBlockedDomains = $sqlHelpers->getCachedUserDomainBlocks($loggedInUser); + $this->cachedUserBlockedHashtags = $sqlHelpers->getCachedUserHashtagBlocks($loggedInUser); $this->cachedUserBlockedMagazines = $sqlHelpers->getCachedUserMagazineBlocks($loggedInUser); } } diff --git a/src/Repository/EntryCommentRepository.php b/src/Repository/EntryCommentRepository.php index 1e5e0136dc..510ac09c5d 100644 --- a/src/Repository/EntryCommentRepository.php +++ b/src/Repository/EntryCommentRepository.php @@ -14,6 +14,7 @@ use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\EntryCommentFavourite; +use App\Entity\HashtagBlock; use App\Entity\HashtagLink; use App\Entity\Image; use App\Entity\MagazineBlock; @@ -55,11 +56,11 @@ public function __construct( parent::__construct($registry, EntryComment::class); } - public function findByCriteria(Criteria $criteria): Pagerfanta + public function findByCriteria(Criteria $criteria, ?User $loggedInUser = null): Pagerfanta { $pagerfanta = new Pagerfanta( new QueryAdapter( - $this->getEntryQueryBuilder($criteria), + $this->getEntryQueryBuilder($criteria, $loggedInUser), false ) ); @@ -74,9 +75,9 @@ public function findByCriteria(Criteria $criteria): Pagerfanta return $pagerfanta; } - private function getEntryQueryBuilder(Criteria $criteria): QueryBuilder + private function getEntryQueryBuilder(Criteria $criteria, ?User $user): QueryBuilder { - $user = $this->security->getUser(); + $user = $user ?? $this->security->getUser(); $qb = $this->createQueryBuilder('c') ->select('c', 'u') @@ -103,7 +104,7 @@ private function getEntryQueryBuilder(Criteria $criteria): QueryBuilder ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); $this->addTimeClause($qb, $criteria); - $this->filter($qb, $criteria); + $this->filter($qb, $criteria, $user); $this->addBannedHashtagClause($qb); if ($user instanceof User) { $this->filterWords($qb, $user); @@ -134,10 +135,8 @@ private function addBannedHashtagClause(QueryBuilder $qb): void $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); } - private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder + private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): QueryBuilder { - $user = $this->security->getUser(); - if (Criteria::AP_LOCAL === $criteria->federation) { $qb->andWhere('c.apId IS NULL'); } @@ -200,14 +199,14 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder $qb->andWhere( 'c.magazine IN (SELECT IDENTITY(cm.magazine) FROM '.Moderator::class.' cm WHERE cm.user = :user)' ); - $qb->setParameter('user', $this->security->getUser()); + $qb->setParameter('user', $user); } if ($criteria->favourite) { $qb->andWhere( 'c.id IN (SELECT IDENTITY(cf.entryComment) FROM '.EntryCommentFavourite::class.' cf WHERE cf.user = :user)' ); - $qb->setParameter('user', $this->security->getUser()); + $qb->setParameter('user', $user); } if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { @@ -223,6 +222,13 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder 'c.magazine NOT IN (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :blocker)' ); + $qb->andWhere( + 'NOT EXISTS (' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hl ON hb.hashtag = hl.hashtag ' + .'WHERE hl.entryComment = c AND hb.user = :blocker' + .')' + ); + if (!$criteria->domain) { $qb->andWhere( 'ce.domain IS null OR ce.domain NOT IN (SELECT IDENTITY(db.domain) FROM '.DomainBlock::class.' db WHERE db.user = :blocker)' diff --git a/src/Repository/PostCommentRepository.php b/src/Repository/PostCommentRepository.php index c5f9035335..0e6c1d7620 100644 --- a/src/Repository/PostCommentRepository.php +++ b/src/Repository/PostCommentRepository.php @@ -9,6 +9,7 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\HashtagBlock; use App\Entity\HashtagLink; use App\Entity\Image; use App\Entity\Post; @@ -46,7 +47,7 @@ public function __construct( parent::__construct($registry, PostComment::class); } - public function findByCriteria(PostCommentPageView $criteria) + public function findByCriteria(PostCommentPageView $criteria, ?User $loggedInUser = null) { // return $this->createQueryBuilder('pc') // ->orderBy('pc.createdAt', 'DESC') @@ -55,7 +56,7 @@ public function findByCriteria(PostCommentPageView $criteria) // ->getResult(); $pagerfanta = new Pagerfanta( new QueryAdapter( - $this->getCommentQueryBuilder($criteria), + $this->getCommentQueryBuilder($criteria, $loggedInUser), false ) ); @@ -70,9 +71,9 @@ public function findByCriteria(PostCommentPageView $criteria) return $pagerfanta; } - private function getCommentQueryBuilder(Criteria $criteria): QueryBuilder + private function getCommentQueryBuilder(Criteria $criteria, ?User $user): QueryBuilder { - $user = $this->security->getUser(); + $user = $user ?? $this->security->getUser(); $qb = $this->createQueryBuilder('c') ->select('c', 'u') @@ -99,7 +100,7 @@ private function getCommentQueryBuilder(Criteria $criteria): QueryBuilder ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); $this->addTimeClause($qb, $criteria); - $this->filter($qb, $criteria); + $this->filter($qb, $criteria, $user); $this->addBannedHashtagClause($qb); if ($user instanceof User) { @@ -131,7 +132,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); } - private function filter(QueryBuilder $qb, Criteria $criteria) + private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): void { if ($criteria->post) { $qb->andWhere('c.post = :post') @@ -160,12 +161,18 @@ private function filter(QueryBuilder $qb, Criteria $criteria) ->setParameter('tag', $criteria->tag); } - $user = $this->security->getUser(); if ($user && !$criteria->moderated) { $qb->andWhere( 'c.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)' ); + $qb->andWhere( + 'NOT EXISTS (' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hl ON hb.hashtag = hl.hashtag ' + .'WHERE hl.postComment = c AND hb.user = :blocker' + .')' + ); + $qb->setParameter('blocker', $user); } diff --git a/src/Service/TagManager.php b/src/Service/TagManager.php index b637adfba3..474ae5f4d7 100644 --- a/src/Service/TagManager.php +++ b/src/Service/TagManager.php @@ -11,10 +11,13 @@ use App\Entity\Hashtag; use App\Entity\Post; use App\Entity\PostComment; +use App\Entity\User; +use App\Event\HashtagBlockChangedEvent; use App\Repository\TagLinkRepository; use App\Repository\TagRepository; use Doctrine\ORM\EntityManagerInterface; use JetBrains\PhpStorm\ArrayShape; +use Psr\EventDispatcher\EventDispatcherInterface; class TagManager { @@ -23,6 +26,7 @@ public function __construct( private readonly TagLinkRepository $tagLinkRepository, private readonly EntityManagerInterface $entityManager, private readonly TagExtractor $tagExtractor, + private readonly EventDispatcherInterface $dispatcher, ) { } @@ -177,4 +181,18 @@ public function isAnyTagBanned(?array $tags): bool return false; } + + public function block(User $user, Hashtag $hashtag): void { + $user->blockHashtag($hashtag); + $this->entityManager->flush(); + + $this->dispatcher->dispatch(new HashtagBlockChangedEvent($hashtag, $user, true)); + } + + public function unblock(User $user, Hashtag $hashtag): void { + $user->unblockHashtag($hashtag); + $this->entityManager->flush(); + + $this->dispatcher->dispatch(new HashtagBlockChangedEvent($hashtag, $user, false)); + } } diff --git a/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php index 3177acfe22..dadbc814d9 100644 --- a/src/Utils/SqlHelpers.php +++ b/src/Utils/SqlHelpers.php @@ -27,6 +27,7 @@ class SqlHelpers public const string USER_BLOCKS_KEY = 'cached_user_blocks_'; public const string USER_MAGAZINE_BLOCKS_KEY = 'cached_user_magazine_block_'; public const string USER_DOMAIN_BLOCKS_KEY = 'cached_user_domain_block_'; + public const string USER_HASHTAG_BLOCKS_KEY = 'cached_user_hashtag_block_'; public function __construct( private readonly EntityManagerInterface $entityManager, @@ -164,7 +165,7 @@ public function getCachedUserFollows(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached user follows of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -195,7 +196,7 @@ public function getCachedUserSubscribedMagazines(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached subscribed magazines of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -207,7 +208,7 @@ public function clearCachedUserSubscribedMagazines(User $user): void try { $this->cache->delete(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached subscribed Magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + $this->logger->warning('There was an error clearing the cached subscribed magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } @@ -226,7 +227,7 @@ public function getCachedUserModeratedMagazines(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached moderated magazines of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -257,7 +258,7 @@ public function getCachedUserSubscribedDomains(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached subscribed domains of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -274,7 +275,7 @@ public function clearCachedUserSubscribedDomains(User $user): void } /** - * @return int[] the ids of the domains $user is subscribed to + * @return int[] the ids of the users $user has blocked */ public function getCachedUserBlocks(User $user): array { @@ -288,7 +289,7 @@ public function getCachedUserBlocks(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached blocked users of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -300,12 +301,12 @@ public function clearCachedUserBlocks(User $user): void try { $this->cache->delete(self::USER_BLOCKS_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached blocked user of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + $this->logger->warning('There was an error clearing the cached blocked users of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** - * @return int[] the ids of the domains $user is subscribed to + * @return int[] the ids of the magazines $user has blocked */ public function getCachedUserMagazineBlocks(User $user): array { @@ -319,7 +320,7 @@ public function getCachedUserMagazineBlocks(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached blocked magazines of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -336,7 +337,7 @@ public function clearCachedUserMagazineBlocks(User $user): void } /** - * @return int[] the ids of the domains $user is subscribed to + * @return int[] the ids of the domains $user has blocked */ public function getCachedUserDomainBlocks(User $user): array { @@ -350,7 +351,7 @@ public function getCachedUserDomainBlocks(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached blocked domains of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -366,6 +367,37 @@ public function clearCachedUserDomainBlocks(User $user): void } } + /** + * @return int[] the ids of the hashtags $user has blocked + */ + public function getCachedUserHashtagBlocks(User $user): array + { + try { + $sql = 'SELECT hashtag_id FROM hashtag_block WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_HASHTAG_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } catch (InvalidArgumentException|Exception $exception) { + $this->logger->error('There was an error getting the cached blocked hashtags of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + + return []; + } + } + + public function clearCachedUserHashtagBlocks(User $user): void + { + $this->logger->debug('Clearing cached hashtag blocks for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_HASHTAG_BLOCKS_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached blocked hashtags of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + /** * @param string $sql the sql to fetch the single column, should contain a 'uId' Parameter * diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php index cac582ac2d..2c46e2203f 100644 --- a/tests/FactoryTrait.php +++ b/tests/FactoryTrait.php @@ -18,6 +18,7 @@ use App\Entity\Contracts\VotableInterface; use App\Entity\Entry; use App\Entity\EntryComment; +use App\Entity\Hashtag; use App\Entity\Image; use App\Entity\Magazine; use App\Entity\Message; @@ -502,6 +503,28 @@ public function createImage(string $fileName): Image return $image; } + public function createHashtag(string $name): Hashtag { + $tag = new Hashtag(); + $tag->tag = $name; + + $this->entityManager->persist($tag); + $this->entityManager->flush(); + + $this->hashtags[] = $tag; + + return $tag; + } + + public function getHashtag(string $name): Hashtag { + $tag = $this->hashtags->filter(fn (Hashtag $tag) => $tag->tag === $name)->first(); + + if(!$tag) { + $tag = $this->createHashtag($name); + } + + return $tag; + } + public function createMessageNotification(?User $to = null, ?User $from = null): Notification { $messageManager = $this->messageManager; diff --git a/tests/Functional/Service/Hashtag/TagBlockTest.php b/tests/Functional/Service/Hashtag/TagBlockTest.php new file mode 100644 index 0000000000..aa81094323 --- /dev/null +++ b/tests/Functional/Service/Hashtag/TagBlockTest.php @@ -0,0 +1,132 @@ +getUserByUsername('John Doe'); + $user2 = $this->getUserByUsername('Jane Doe'); + $tagNeutral = $this->getHashtag('abc'); + $tagBlocked = $this->getHashtag('def'); + + $this->tagManager->block($user1, $tagBlocked); + + self::assertCount(1, $user1->blockedHashtags); + self::assertSame($tagBlocked->tag, $user1->blockedHashtags[0]->hashtag->tag); + self::assertCount(0, $user2->blockedHashtags); + } + + public function testUnblock() { + $user1 = $this->getUserByUsername('John Doe'); + $user2 = $this->getUserByUsername('Jane Doe'); + $tag1 = $this->getHashtag('abc'); + $tag2 = $this->getHashtag('def'); + + $this->tagManager->block($user1, $tag1); + $this->tagManager->block($user1, $tag2); + $this->tagManager->block($user2, $tag1); + + $this->tagManager->unblock($user1, $tag1); + + self::assertCount(1, $user1->blockedHashtags); + self::assertSame($tag2->tag, $user1->blockedHashtags->first()->hashtag->tag); + self::assertCount(1, $user2->blockedHashtags); + self::assertSame($tag1->tag, $user2->blockedHashtags->first()->hashtag->tag); + } + + public function testBlockedHashtagIsHiddenInCombined() { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('notWanted'); + + $magazine = $this->getMagazineByName('HashtagBlockTest'); + $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #wanted'); + $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notWanted'); + usleep(10000); + $entryCommentShowing = $this->createEntryComment('some text #wanted', $entryShowing, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notWanted', $entryShowing, $contentCreator); + usleep(10000); + $postShowing = $this->createPost('some text #wanted', $magazine, $contentCreator); + $postHidden = $this->createPost('some text #notWanted', $magazine, $contentCreator); + usleep(10000); + $postCommentShowing = $this->createPostComment('some text #wanted', $postShowing, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notWanted', $postShowing, $contentCreator); + + $user->follow($contentCreator); + $this->tagManager->block($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_NEW); + $criteria->magazine = $magazine; + $criteria->includeBoosts = true; + $criteria->perPage = 5; + $criteria->fetchCachedItems($this->sqlHelpers, $user); + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertSame($entryCommentShowing->getId(), $result[1]->getId()); + self::assertSame($postShowing->getId(), $result[2]->getId()); + self::assertSame($postCommentShowing->getId(), $result[3]->getId()); + self::assertCount(4, $result); + } + + public function testBlockedHashtagIsHiddenInEntryComments() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('notWanted'); + + $magazine = $this->getMagazineByName('HashtagBlockTest'); + $entry = $this->createEntry('something', $magazine, $contentCreator, body: 'some text'); + $commentShowing = $this->createEntryComment('some text #wanted', $entry, $contentCreator); + $commentHidden = $this->createEntryComment('some text #notWanted', $entry, $contentCreator); + + $this->tagManager->block($user, $tag); + + $criteria = new EntryCommentPageView(1, $this->security); + $criteria->showSortOption(Criteria::SORT_NEW); + $criteria->entry = $entry; + + $fanta = $this->entryCommentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertSame($commentShowing->getId(), $result[0]->getId()); + self::assertCount(1, $result); + } + + public function testBlockedHashtagIsHiddenInPostComments() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('notWanted'); + + $magazine = $this->getMagazineByName('HashtagBlockTest'); + $post = $this->createPost('something', $magazine, $contentCreator); + $commentShowing = $this->createPostComment('some text #wanted', $post, $contentCreator); + $commentHidden = $this->createPostComment('some text #notWanted', $post, $contentCreator); + + $this->tagManager->block($user, $tag); + + $criteria = new PostCommentPageView(1, $this->security); + $criteria->showSortOption(Criteria::SORT_NEW); + $criteria->post = $post; + + $fanta = $this->postCommentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertSame($commentShowing->getId(), $result[0]->getId()); + self::assertCount(1, $result); + } + +} \ No newline at end of file diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 667bfd3a41..68c81c827a 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -55,11 +55,13 @@ use App\Service\ProjectInfoService; use App\Service\ReportManager; use App\Service\SettingsManager; +use App\Service\TagManager; use App\Service\UserManager; use App\Service\VoteManager; use App\Tests\Service\TestingApHttpClient; use App\Tests\Service\TestingImageManager; use App\Twig\Runtime\FormattingExtensionRuntime; +use App\Utils\SqlHelpers; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use League\Flysystem\Filesystem; @@ -108,6 +110,7 @@ abstract class WebTestCase extends BaseWebTestCase protected ArrayCollection $users; protected ArrayCollection $magazines; protected ArrayCollection $entries; + protected ArrayCollection $hashtags; protected EntityManagerInterface $entityManager; protected KernelBrowser $client; @@ -123,6 +126,7 @@ abstract class WebTestCase extends BaseWebTestCase protected VoteManager $voteManager; protected SettingsManager $settingsManager; protected DomainManager $domainManager; + protected TagManager $tagManager; protected ReportManager $reportManager; protected BadgeManager $badgeManager; protected NotificationManager $notificationManager; @@ -177,6 +181,8 @@ abstract class WebTestCase extends BaseWebTestCase protected ActivityJsonBuilder $activityJsonBuilder; protected Security $security; + protected SqlHelpers $sqlHelpers; + protected DeliverHandler $deliverHandler; protected string $kibbyPath; @@ -186,6 +192,8 @@ public function setUp(): void $this->users = new ArrayCollection(); $this->magazines = new ArrayCollection(); $this->entries = new ArrayCollection(); + $this->hashtags = new ArrayCollection(); + $this->kibbyPath = \dirname(__FILE__).'/assets/kibby_emoji.png'; $this->client = static::createClient(); @@ -220,6 +228,7 @@ public function setUp(): void $this->voteManager = $this->getService(VoteManager::class); $this->settingsManager = $this->getService(SettingsManager::class); $this->domainManager = $this->getService(DomainManager::class); + $this->tagManager = $this->getService(TagManager::class); $this->reportManager = $this->getService(ReportManager::class); $this->badgeManager = $this->getService(BadgeManager::class); $this->notificationManager = $this->getService(NotificationManager::class); @@ -272,6 +281,8 @@ public function setUp(): void $this->projectInfoService = $this->getService(ProjectInfoService::class); $this->logger = $this->getService(LoggerInterface::class); + $this->sqlHelpers = $this->getService(SqlHelpers::class); + // clear all cache before every test $app = new Application($this->client->getKernel()); $command = $app->get('cache:pool:clear'); From 52c26e5a0caf599157661910a1ae5082cbacf478 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sat, 25 Jul 2026 22:12:38 +0000 Subject: [PATCH 02/16] add block button to tag page --- config/mbin_routes/tag.yaml | 12 +++- src/Controller/Tag/TagBlockController.php | 65 ++++++++++++++++++++ src/Controller/Tag/TagOverviewController.php | 7 ++- src/Twig/Components/HashtagSubComponent.php | 36 +++++++++++ templates/components/hashtag_sub.html.twig | 27 ++++++++ templates/tag/_panel.html.twig | 1 + 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/Controller/Tag/TagBlockController.php create mode 100644 src/Twig/Components/HashtagSubComponent.php create mode 100644 templates/components/hashtag_sub.html.twig diff --git a/config/mbin_routes/tag.yaml b/config/mbin_routes/tag.yaml index 2165a47aae..5e5d3c39b1 100644 --- a/config/mbin_routes/tag.yaml +++ b/config/mbin_routes/tag.yaml @@ -39,4 +39,14 @@ tag_ban: tag_unban: path: /tag/{name}/unban methods: [POST] - controller: App\Controller\Tag\TagBanController::unban \ No newline at end of file + controller: App\Controller\Tag\TagBanController::unban + +tag_block: + controller: App\Controller\Tag\TagBlockController::block + path: /tag/{name}/block + methods: [ POST ] + +tag_unblock: + controller: App\Controller\Tag\TagBlockController::unblock + path: /tag/{name}/unblock + methods: [ POST ] diff --git a/src/Controller/Tag/TagBlockController.php b/src/Controller/Tag/TagBlockController.php new file mode 100644 index 0000000000..278572f303 --- /dev/null +++ b/src/Controller/Tag/TagBlockController.php @@ -0,0 +1,65 @@ + 'tag'])] Hashtag $tag, Request $request): Response + { + $this->manager->block($this->getUserOrThrow(), $tag); + + if ($request->isXmlHttpRequest()) { + return $this->getJsonResponse($tag); + } + + return $this->redirectToRefererOrHome($request); + } + + #[IsGranted('ROLE_USER')] + public function unblock(#[MapEntity(mapping: ['name' => 'tag'])] Hashtag $tag, Request $request): Response + { + $this->manager->unblock($this->getUserOrThrow(), $tag); + + if ($request->isXmlHttpRequest()) { + return $this->getJsonResponse($tag); + } + + return $this->redirectToRefererOrHome($request); + } + + private function getJsonResponse(Hashtag $tag): JsonResponse + { + return new JsonResponse( + [ + 'html' => $this->renderView( + 'components/_ajax.html.twig', + [ + 'component' => 'hashtag_sub', + 'attributes' => [ + 'hashtag' => $tag, + ], + ] + ), + ] + ); + } +} diff --git a/src/Controller/Tag/TagOverviewController.php b/src/Controller/Tag/TagOverviewController.php index 33d8adcbb5..84e6860dde 100644 --- a/src/Controller/Tag/TagOverviewController.php +++ b/src/Controller/Tag/TagOverviewController.php @@ -23,12 +23,17 @@ public function __construct( public function __invoke(string $name, Request $request): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + $activity = $this->tagRepository->findOverall( $this->getPageNb($request), - $this->tagManager->transliterate(strtolower($name)) + $tag ); $params = [ + 'hashtag' => $hashtag, 'tag' => $name, 'results' => $this->overviewManager->buildList($activity), 'pagination' => $activity, diff --git a/src/Twig/Components/HashtagSubComponent.php b/src/Twig/Components/HashtagSubComponent.php new file mode 100644 index 0000000000..fdeb309eae --- /dev/null +++ b/src/Twig/Components/HashtagSubComponent.php @@ -0,0 +1,36 @@ +security->getUser(); + if ($user instanceof User) { + $this->isHashtagBlocked = $user->isBlockedHashtag($this->hashtag); + } else { + $this->isHashtagBlocked = false; + } + } +} diff --git a/templates/components/hashtag_sub.html.twig b/templates/components/hashtag_sub.html.twig new file mode 100644 index 0000000000..7e9394c6ce --- /dev/null +++ b/templates/components/hashtag_sub.html.twig @@ -0,0 +1,27 @@ + + {# +
+ {{ domain.subscriptionsCount }} +
+
+ +
+ #} +
+ +
+ diff --git a/templates/tag/_panel.html.twig b/templates/tag/_panel.html.twig index 4fdb29f24d..b83c58031d 100644 --- a/templates/tag/_panel.html.twig +++ b/templates/tag/_panel.html.twig @@ -11,6 +11,7 @@ + {{ component('hashtag_sub', {hashtag: hashtag}) }} {{ component('tag_actions', {tag: tag}) }} {% if false %} From cc85239ebaad1d6830922363ddfbc8969deb3d96 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sat, 25 Jul 2026 22:43:55 +0000 Subject: [PATCH 03/16] add list of blocked hashtags to user profile page --- config/mbin_routes/user.yaml | 5 +++++ .../User/Profile/UserBlockController.php | 15 +++++++++++++ src/Repository/TagRepository.php | 20 +++++++++++++++++ .../layout/_hashtag_activity_list.html.twig | 22 +++++++++++++++++++ .../user/settings/block_hashtags.html.twig | 21 ++++++++++++++++++ templates/user/settings/block_pills.html.twig | 6 +++++ .../User/Profile/UserBlockControllerTest.php | 14 ++++++++++++ tests/WebTestCase.php | 3 +++ translations/messages.en.yaml | 2 ++ 9 files changed, 108 insertions(+) create mode 100644 templates/layout/_hashtag_activity_list.html.twig create mode 100644 templates/user/settings/block_hashtags.html.twig diff --git a/config/mbin_routes/user.yaml b/config/mbin_routes/user.yaml index ae8bf15786..cecb695ef4 100644 --- a/config/mbin_routes/user.yaml +++ b/config/mbin_routes/user.yaml @@ -160,6 +160,11 @@ user_settings_domain_blocks: path: /settings/blocked/domains methods: [GET] +user_settings_tag_blocks: + controller: App\Controller\User\Profile\UserBlockController::hashtags + path: /settings/blocked/tags + methods: [GET] + user_settings_user_blocks: controller: App\Controller\User\Profile\UserBlockController::users path: /settings/blocked/people diff --git a/src/Controller/User/Profile/UserBlockController.php b/src/Controller/User/Profile/UserBlockController.php index 1d2f92a543..005adeb5f3 100644 --- a/src/Controller/User/Profile/UserBlockController.php +++ b/src/Controller/User/Profile/UserBlockController.php @@ -7,6 +7,7 @@ use App\Controller\AbstractController; use App\Repository\DomainRepository; use App\Repository\MagazineRepository; +use App\Repository\TagRepository; use App\Repository\UserRepository; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -55,4 +56,18 @@ public function domains(DomainRepository $repository, Request $request): Respons ] ); } + + #[IsGranted('ROLE_USER')] + public function hashtags(TagRepository $repository, Request $request): Response + { + $user = $this->getUserOrThrow(); + + return $this->render( + 'user/settings/block_hashtags.html.twig', + [ + 'user' => $user, + 'hashtags' => $repository->findBlockedTags($this->getPageNb($request), $user), + ] + ); + } } diff --git a/src/Repository/TagRepository.php b/src/Repository/TagRepository.php index 97f24b28d5..9b66167a5d 100644 --- a/src/Repository/TagRepository.php +++ b/src/Repository/TagRepository.php @@ -6,11 +6,13 @@ use App\Entity\Contracts\VisibilityInterface; use App\Entity\Hashtag; +use App\Entity\User; use App\Pagination\NativeQueryAdapter; use App\Pagination\Transformation\ContentPopulationTransformer; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; use JetBrains\PhpStorm\ArrayShape; +use Pagerfanta\Doctrine\Collections\CollectionAdapter; use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; @@ -116,4 +118,22 @@ public function getCounts(string $tag): ?array 'post_comment' => 0, ]; } + + public function findBlockedTags(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta + { + $pagerfanta = new Pagerfanta( + new CollectionAdapter( + $user->blockedHashtags + ) + ); + + try { + $pagerfanta->setMaxPerPage($perPage); + $pagerfanta->setCurrentPage($page); + } catch (NotValidCurrentPageException $e) { + throw new NotFoundHttpException(); + } + + return $pagerfanta; + } } diff --git a/templates/layout/_hashtag_activity_list.html.twig b/templates/layout/_hashtag_activity_list.html.twig new file mode 100644 index 0000000000..df96b4dfb6 --- /dev/null +++ b/templates/layout/_hashtag_activity_list.html.twig @@ -0,0 +1,22 @@ +{% if list|length %} +
+
    + {% for subject in list %} +
  • +
    + {{ subject.hashtag.tag }} + {{ component('date', {date: subject.createdAt}) }} +
    +
  • + {% endfor %} +
+
+ {% if(list.haveToPaginate is defined and list.haveToPaginate) %} + {{ pagerfanta(list, null, {'pageParameter':'[p]'}) }} + {% endif %} +{% else %} + +{% endif %} \ No newline at end of file diff --git a/templates/user/settings/block_hashtags.html.twig b/templates/user/settings/block_hashtags.html.twig new file mode 100644 index 0000000000..f0233478a3 --- /dev/null +++ b/templates/user/settings/block_hashtags.html.twig @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'blocked'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} +{%- endblock -%} + + +{% block mainClass %}page-settings page-settings-block-magazines{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} + {% include 'user/settings/_options.html.twig' %} + {% include('user/_visibility_info.html.twig') %} + {% include 'user/settings/block_pills.html.twig' %} + {% include 'layout/_hashtag_activity_list.html.twig' with {list: hashtags} %} +{% endblock %} diff --git a/templates/user/settings/block_pills.html.twig b/templates/user/settings/block_pills.html.twig index ab0b803c70..2ff8abfd13 100644 --- a/templates/user/settings/block_pills.html.twig +++ b/templates/user/settings/block_pills.html.twig @@ -12,6 +12,12 @@ {{ 'people'|trans }} +
  • + + {{ 'hashtags'|trans }} + +
  • diff --git a/tests/Functional/Controller/User/Profile/UserBlockControllerTest.php b/tests/Functional/Controller/User/Profile/UserBlockControllerTest.php index ce4841aeee..96bc6129b1 100644 --- a/tests/Functional/Controller/User/Profile/UserBlockControllerTest.php +++ b/tests/Functional/Controller/User/Profile/UserBlockControllerTest.php @@ -49,4 +49,18 @@ public function testUserCanSeeBlockedDomains() $this->assertSelectorTextContains('#main .pills .active', 'Domains'); $this->assertSelectorTextContains('#main', 'kbin.pub'); } + + public function testUserCanSeeBlockedHashtags() + { + $this->client->loginUser($user = $this->getUserByUsername('JaneDoe')); + + $tag = $this->getHashtag('taghash'); + $this->tagManager->block($user, $tag); + + $crawler = $this->client->request('GET', '/settings/blocked/tags'); + $this->client->click($crawler->filter('#main .pills')->selectLink('Hashtags')->link()); + + $this->assertSelectorTextContains('#main .pills .active', 'Hashtags'); + $this->assertSelectorTextContains('#main', 'taghash'); + } } diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 68c81c827a..9d4d3dacab 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -31,6 +31,7 @@ use App\Repository\SettingsRepository; use App\Repository\SiteRepository; use App\Repository\TagLinkRepository; +use App\Repository\TagRepository; use App\Repository\UserFollowRepository; use App\Repository\UserRepository; use App\Service\ActivityPub\ActivityJsonBuilder; @@ -149,6 +150,7 @@ abstract class WebTestCase extends BaseWebTestCase protected SettingsRepository $settingsRepository; protected UserRepository $userRepository; protected TagLinkRepository $tagLinkRepository; + protected TagRepository $tagRepository; protected BookmarkRepository $bookmarkRepository; protected BookmarkListRepository $bookmarkListRepository; protected UserFollowRepository $userFollowRepository; @@ -253,6 +255,7 @@ public function setUp(): void $this->settingsRepository = $this->getService(SettingsRepository::class); $this->userRepository = $this->getService(UserRepository::class); $this->tagLinkRepository = $this->getService(TagLinkRepository::class); + $this->tagRepository = $this->getService(TagRepository::class); $this->bookmarkRepository = $this->getService(BookmarkRepository::class); $this->bookmarkListRepository = $this->getService(BookmarkListRepository::class); $this->userFollowRepository = $this->getService(UserFollowRepository::class); diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 2aa4828a5f..f289c30b45 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -140,6 +140,8 @@ title: Title body: Body tags: Tags tag: Tag +hashtag: Hashtag +hashtags: Hashtags badges: Badges is_adult: 18+ / NSFW eng: ENG From 8ae4075b27cb2c2ef14c307d5b221fdcf78b17e8 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sun, 26 Jul 2026 15:59:08 +0000 Subject: [PATCH 04/16] add API to list blocked hashtags --- config/mbin_routes/tag_api.yaml | 18 + config/packages/league_oauth2_server.yaml | 2 + config/packages/nelmio_api_doc.yaml | 16 +- config/packages/security.yaml | 3 + docs/04-app_developers/README.md | 399 +++++++++--------- src/Controller/Api/Tag/TagBaseApi.php | 36 ++ src/Controller/Api/Tag/TagBlockApi.php | 216 ++++++++++ src/DTO/HashtagResponseDto.php | 47 +++ src/DTO/OAuth2ClientDto.php | 2 + src/Entity/OAuth2UserConsent.php | 4 + src/Factory/HashtagFactory.php | 45 ++ .../Controller/Api/Tag/TagBlockApiTest.php | 206 +++++++++ tests/WebTestCase.php | 1 + 13 files changed, 795 insertions(+), 200 deletions(-) create mode 100644 src/Controller/Api/Tag/TagBaseApi.php create mode 100644 src/Controller/Api/Tag/TagBlockApi.php create mode 100644 src/DTO/HashtagResponseDto.php create mode 100644 src/Factory/HashtagFactory.php create mode 100644 tests/Functional/Controller/Api/Tag/TagBlockApiTest.php diff --git a/config/mbin_routes/tag_api.yaml b/config/mbin_routes/tag_api.yaml index bb63d231c7..ca256bf350 100644 --- a/config/mbin_routes/tag_api.yaml +++ b/config/mbin_routes/tag_api.yaml @@ -21,3 +21,21 @@ api_tag_post_comments: path: /api/tag/{name}/postComments methods: [ GET ] format: json + +api_tag_block: + controller: App\Controller\Api\Tag\TagBlockApi::block + path: /api/tag/{name}/block + methods: [ PUT ] + format: json + +api_tag_unblock: + controller: App\Controller\Api\Tag\TagBlockApi::unblock + path: /api/tag/{name}/unblock + methods: [ PUT ] + format: json + +api_tag_blocked: + controller: App\Controller\Api\Tag\TagBlockApi::list + path: /api/tags/blocked + methods: [ GET ] + format: json diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index f7388c121c..465835930e 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -28,6 +28,8 @@ league_oauth2_server: "domain", "domain:subscribe", "domain:block", + "hashtag", + "hashtag:block", "entry", "entry:create", "entry:edit", diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index f728838bc8..1f087315fe 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -109,11 +109,13 @@ nelmio_api_doc: delete: Delete any of your threads, posts, or comments. report: Report threads, posts, or comments. vote: Upvote, downvote, or boost threads, posts, or comments. - subscribe: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribe to. + subscribe: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribed to. block: Block or unblock any magazine, domain, or user, and view the magazines, domains, and users you have blocked. - domain: Subscribe to or block domains, and view the domains you subscribe to or block. - domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribe to. + domain: Subscribe to or block domains, and view the domains you subscribed to or block. + domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribed to. domain:block: Block or unblock domains and view the domains you have blocked. + hashtag: Block hashtags, and view the hashtags you subscribed to. + hashtag:block: Block or unblock hashtags and view the hashtags you have blocked. entry: Create, edit, or delete your threads, and vote, boost, or report any thread. entry:create: Create new threads. entry:edit: Edit your existing threads. @@ -226,11 +228,13 @@ nelmio_api_doc: delete: Delete any of your threads, posts, or comments. subscribe: Report threads, posts, or comments. block: Upvote, downvote, or boost threads, posts, or comments. - vote: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribe to. + vote: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribed to. report: Block or unblock any magazine, domain, or user, and view the magazines, domains, and users you have blocked. - domain: Subscribe to or block domains, and view the domains you subscribe to or block. - domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribe to. + domain: Subscribe to or block domains, and view the domains you subscribed to or block. + domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribed to. domain:block: Block or unblock domains and view the domains you have blocked. + hashtag: Block hashtags, and view the hashtags you block. + hashtag:block: Block or unblock hashtags and view the hashtags you have blocked. entry: Create, edit, or delete your threads, and vote, boost, or report any thread. entry:create: Create new threads. entry:edit: Edit your existing threads. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 37d5f8858e..bdac4d49cf 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -202,11 +202,14 @@ security: ROLE_OAUTH2_BLOCK: [ 'ROLE_OAUTH2_DOMAIN:BLOCK', + 'ROLE_OAUTH2_HASHTAG:BLOCK', 'ROLE_OAUTH2_MAGAZINE:BLOCK', 'ROLE_OAUTH2_USER:BLOCK', ] ROLE_OAUTH2_DOMAIN: ['ROLE_OAUTH2_DOMAIN:SUBSCRIBE', 'ROLE_OAUTH2_DOMAIN:BLOCK'] + ROLE_OAUTH2_HASHTAG: + ['ROLE_OAUTH2_HASHTAG:BLOCK'] ROLE_OAUTH2_ENTRY: [ 'ROLE_OAUTH2_ENTRY:CREATE', diff --git a/docs/04-app_developers/README.md b/docs/04-app_developers/README.md index 73547ae411..8c829133db 100644 --- a/docs/04-app_developers/README.md +++ b/docs/04-app_developers/README.md @@ -13,19 +13,19 @@ Or use the Swagger documentation on an existing Mbin instance: `https://mbin_sit ### Available Grants 1. `client_credentials` - - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/) - - Best used for bots and clients that only ever need to authenticate as a single user, from a trusted device. - - Note that bots authenticating with this grant type will be distinguished as bots and will not be allowed to vote on content. + - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/) + - Best used for bots and clients that only ever need to authenticate as a single user, from a trusted device. + - Note that bots authenticating with this grant type will be distinguished as bots and will not be allowed to vote on content. 2. `authorization_code` - - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/authorization-code-request/) - - public clients must use [PKCE](https://www.oauth.com/oauth2-servers/pkce/) to authenticate. - - A public client is any client that will be installed on a device that is not controlled by the client's creator - - Native apps - - Single page web apps - - Or similar + - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/authorization-code-request/) + - public clients must use [PKCE](https://www.oauth.com/oauth2-servers/pkce/) to authenticate. + - A public client is any client that will be installed on a device that is not controlled by the client's creator + - Native apps + - Single page web apps + - Or similar 3. `refresh_token` - - [documentation here](https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/) - - Refresh tokens are used with the `authorization_code` grant type to reduce the number of times the user must log in. + - [documentation here](https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/) + - Refresh tokens are used with the `authorization_code` grant type to reduce the number of times the user must log in. ### Obtaining OAuth2 credentials from a new server @@ -70,38 +70,42 @@ POST /api/client 3. Use the OAuth2 client id (`identifier`) and `secret` you just created to obtain credentials for a user (This is a standard authorization_code OAuth2 flow, which is supported by many libraries for your preferred language) - 1. Begin authorization_code OAuth2 flow, by providing the `/authorize` endpint with the following query parameters: + 1. Begin authorization_code OAuth2 flow, by providing the `/authorize` endpint with the following query parameters: - ``` - GET /authorize?response_type=code&client_id=(the client id generated at client creation)&redirect_uri=(One of the URIs added during client creation)&scope=(space-delimited list of scopes)&state=(random string for CSRF protection) - ``` + ``` + GET /authorize?response_type=code&client_id=(the client id generated at client creation)&redirect_uri=(One of the URIs added during client creation)&scope=(space-delimited list of scopes)&state=(random string for CSRF protection) + ``` - 2. The user will be directed to log in to their account and grant their consent for the scopes you have requested. - 3. When the user grants their consent, their browser will be redirected to the given redirect_uri with a `code` query parameter, as long as it matches one of the URIs provided when the client was created. - 4. After obtaining the code, obtain an authorization token with a `multipart/form-data` POST request towards the `/token` endpoint: + 2. The user will be directed to log in to their account and grant their consent for the scopes you have requested. + 3. When the user grants their consent, their browser will be redirected to the given redirect_uri with a `code` + query parameter, as long as it matches one of the URIs provided when the client was created. + 4. After obtaining the code, obtain an authorization token with a `multipart/form-data` POST request towards the + `/token` endpoint: - ``` - POST /token + ``` + POST /token + + grant_type=authorization_code + client_id=(the client id generated at client creation) + client_secret=(the client secret generated at client creation) + code=(OAuth2 code received from redirect) + redirect_uri=(One of the URIs added during client creation) + ``` - grant_type=authorization_code - client_id=(the client id generated at client creation) - client_secret=(the client secret generated at client creation) - code=(OAuth2 code received from redirect) - redirect_uri=(One of the URIs added during client creation) - ``` + 5. The `/token` endpoint will respond with the access token, refresh token and information about it: - 5. The `/token` endpoint will respond with the access token, refresh token and information about it: + ```json + { + "token_type": "Bearer", + "expires_in": 3600, // seconds + "access_token": "aLargeEncodedTokenToBeUsedInTheAuthorizationHeader", + "refresh_token": "aLargeEncodedTokenToBeUsedInTheRefreshTokenFlow" + } + ``` - ```json - { - "token_type": "Bearer", - "expires_in": 3600, // seconds - "access_token": "aLargeEncodedTokenToBeUsedInTheAuthorizationHeader", - "refresh_token": "aLargeEncodedTokenToBeUsedInTheRefreshTokenFlow" - } - ``` - - 6. Once you have obtained an access token, you can use it to make authenticated requests to the API end-points that need authentication. This is done by adding the `Authorization` header to the request with the value: `Bearer `. + 6. Once you have obtained an access token, you can use it to make authenticated requests to the API end-points that need + authentication. This is done by adding the `Authorization` header to the request with the value: + `Bearer `. ### Available Scopes @@ -109,201 +113,208 @@ POST /api/client 1. `read` - Allows retrieval of threads from the user's subscribed magazines/domains and viewing the user's favorited entries. 2. `write` - Provides all of the following nested scopes - - `entry:create` - - `entry:edit` - - `entry_comment:create` - - `entry_comment:edit` - - `post:create` - - `post:edit` - - `post_comment:create` - - `post_comment:edit` + - `entry:create` + - `entry:edit` + - `entry_comment:create` + - `entry_comment:edit` + - `post:create` + - `post:edit` + - `post_comment:create` + - `post_comment:edit` 3. `delete` - Provides all of the following nested scopes, for deleting the current user's content - - `entry:delete` - - `entry_comment:delete` - - `post:delete` - - `post_comment:delete` + - `entry:delete` + - `entry_comment:delete` + - `post:delete` + - `post_comment:delete` 4. `subscribe` - Provides the following nested scopes - - `domain:subscribe` - - Allows viewing and editing domain subscriptions - - `magazine:subscribe` - - Allows viewing and editing magazine subscriptions - - `user:follow` - - Allows viewing and editing user follows + - `domain:subscribe` + - Allows viewing and editing domain subscriptions + - `magazine:subscribe` + - Allows viewing and editing magazine subscriptions + - `user:follow` + - Allows viewing and editing user follows 5. `block` - Provides the following nested scopes - - `domain:block` - - Allows viewing and editing domain blocks - - `magazine:block` - - Allows viewing and editing magazine blocks - - `user:block` - - Allows viewing and editing user blocks + - `domain:block` + - Allows viewing and editing domain blocks + - `hashtag:block` + - Allows viewing and editing hashtag blocks + - `magazine:block` + - Allows viewing and editing magazine blocks + - `user:block` + - Allows viewing and editing user blocks 6. `vote` - Provides the following nested scopes, for up/down voting and boosting content - - `entry:vote` - - `entry_comment:vote` - - `post:vote` - - `post_comment:vote` + - `entry:vote` + - `entry_comment:vote` + - `post:vote` + - `post_comment:vote` 7. `report` - Provides the following nested scopes - - `entry:report` - - `entry_comment:report` - - `post:report` - - `post_comment:report` + - `entry:report` + - `entry_comment:report` + - `post:report` + - `post_comment:report` 8. `domain` - Provides all domain scopes - - `domain:subscribe` - - `domain:block` -9. `entry` - Provides all entry scopes - - `entry:create` - - `entry:edit` - - `entry:delete` - - `entry:vote` - - `entry:report` -10. `entry_comment` - Provides all entry comment scopes + - `domain:subscribe` + - `domain:block` +9. `hashtag` - Provides all hashtag scopes + - `hashtag:block` +10. `entry` - Provides all entry scopes + - `entry:create` + - `entry:edit` + - `entry:delete` + - `entry:vote` + - `entry:report` +11. `entry_comment` - Provides all entry comment scopes - `entry_comment:create` - `entry_comment:edit` - `entry_comment:delete` - `entry_comment:vote` - `entry_comment:report` -11. `magazine` - Provides all magazine user level scopes +12. `magazine` - Provides all magazine user level scopes - `magazine:subscribe` - `magazine:block` -12. `post` - Provides all post scopes +13. `post` - Provides all post scopes - `post:create` - `post:edit` - `post:delete` - `post:vote` - `post:report` -13. `post_comment` - Provides all post comment scopes +14. `post_comment` - Provides all post comment scopes - `post_comment:create` - `post_comment:edit` - `post_comment:delete` - `post_comment:vote` - `post_comment:report` -14. `user` - Provides all user access scopes +15. `user` - Provides all user access scopes - `user:profile` - - `user:profile:read` - - Allows access to current user's settings and profile via the `/api/user/me` endpoint - - `user:profile:edit` - - Allows updating the current user's settings and profile + - `user:profile:read` + - Allows access to current user's settings and profile via the `/api/user/me` endpoint + - `user:profile:edit` + - Allows updating the current user's settings and profile - `user:message` - - `user:message:read` - - Allows the client to view the current user's messages - - Also allows the client to mark unread messages as read or read messages as unread - - `user:message:create` - - Allows the client to create new messages to other users or reply to existing messages + - `user:message:read` + - Allows the client to view the current user's messages + - Also allows the client to mark unread messages as read or read messages as unread + - `user:message:create` + - Allows the client to create new messages to other users or reply to existing messages - `user:notification` - - `user:notification:read` - - Allows the client to read notifications about threads, posts, or comments being replied to, as well as moderation notifications. - - Does not allow the client to read the content of messages. Message notifications will have their content censored unless the `user:message:read` scope is granted. - - Allows the client to read the number of unread notifications, and mark them as read/unread - - `user:notification:delete` - - Allows the client to clear notifications -15. `moderate` - grants all moderation permissions. The user must be a moderator to perform these actions + - `user:notification:read` + - Allows the client to read notifications about threads, posts, or comments being replied to, as well as + moderation notifications. + - Does not allow the client to read the content of messages. Message notifications will have their content + censored unless the `user:message:read` scope is granted. + - Allows the client to read the number of unread notifications, and mark them as read/unread + - `user:notification:delete` + - Allows the client to clear notifications +16. `moderate` - grants all moderation permissions. The user must be a moderator to perform these actions - `moderate:entry` - Allows the client to retrieve a list of threads from magazines moderated by the user - - `moderate:entry:language` - - Allows changing the language of threads moderated by the user - - `moderate:entry:pin` - - Allows pinning/unpinning threads to the top of magazines moderated by the user - - `moderate:entry:lock` - - Allows locking/unlocking of threads - - `moderate:entry:set_adult` - - Allows toggling the NSFW status of threads moderated by the user - - `moderate:entry:trash` - - Allows soft deletion or restoration of threads moderated by the user + - `moderate:entry:language` + - Allows changing the language of threads moderated by the user + - `moderate:entry:pin` + - Allows pinning/unpinning threads to the top of magazines moderated by the user + - `moderate:entry:lock` + - Allows locking/unlocking of threads + - `moderate:entry:set_adult` + - Allows toggling the NSFW status of threads moderated by the user + - `moderate:entry:trash` + - Allows soft deletion or restoration of threads moderated by the user - `moderate:entry_comment` - - `moderate:entry_comment:language` - - Allows changing the language of comments in threads moderated by the user - - `moderate:entry_comment:set_adult` - - Allows toggling the NSFW status of comments in threads moderated by the user - - `moderate:entry_comment:trash` - - Allows soft deletion or restoration of comments in threads moderated by the user + - `moderate:entry_comment:language` + - Allows changing the language of comments in threads moderated by the user + - `moderate:entry_comment:set_adult` + - Allows toggling the NSFW status of comments in threads moderated by the user + - `moderate:entry_comment:trash` + - Allows soft deletion or restoration of comments in threads moderated by the user - `moderate:post` - - `moderate:post:language` - - Allows changing the language of posts moderated by the user - - `moderate:post:set_adult` - - Allows toggling the NSFW status of posts moderated by the user - - `moderate:post:trash` - - Allows soft deletion or restoration of posts moderated by the user - - `moderate:post:pin` - - Allows pinning/unpinning posts to the top of magazines moderated by the user - - `moderate:post:lock` - - Allows locking/unlocking of posts + - `moderate:post:language` + - Allows changing the language of posts moderated by the user + - `moderate:post:set_adult` + - Allows toggling the NSFW status of posts moderated by the user + - `moderate:post:trash` + - Allows soft deletion or restoration of posts moderated by the user + - `moderate:post:pin` + - Allows pinning/unpinning posts to the top of magazines moderated by the user + - `moderate:post:lock` + - Allows locking/unlocking of posts - `moderate:post_comment` - - `moderate:post_comment:language` - - Allows changing the language of comments on posts moderated by the user - - `moderate:post_comment:set_adult` - - Allows toggling the NSFW status of comments on posts moderated by the user - - `moderate:post_comment:trash` - - Allows soft deletion or restoration of comments on posts moderated by the user + - `moderate:post_comment:language` + - Allows changing the language of comments on posts moderated by the user + - `moderate:post_comment:set_adult` + - Allows toggling the NSFW status of comments on posts moderated by the user + - `moderate:post_comment:trash` + - Allows soft deletion or restoration of comments on posts moderated by the user - `moderate:magazine` - - `moderate:magazine:ban` - - `moderate:magazine:ban:read` - - Allows viewing the users banned from the magazine - - `moderate:magazine:ban:create` - - Allows the client to ban a user from the magazine - - `moderate:magazine:ban:delete` - - Allows the client to unban a user from the magazine - - `moderate:magazine:list` - - Allows the client to view a list of magazines the user moderates - - `moderate:magazine:reports` - - `moderate:magazine:reports:read` - - Allows the client to read reports about content from magazines the user moderates - - `moderate:magazine:reports:action` - - Allows the client to take action on reports, either accepting or rejecting them - - `moderate:magazine:trash:read` - - Allows viewing the removed content of a moderated magazine + - `moderate:magazine:ban` + - `moderate:magazine:ban:read` + - Allows viewing the users banned from the magazine + - `moderate:magazine:ban:create` + - Allows the client to ban a user from the magazine + - `moderate:magazine:ban:delete` + - Allows the client to unban a user from the magazine + - `moderate:magazine:list` + - Allows the client to view a list of magazines the user moderates + - `moderate:magazine:reports` + - `moderate:magazine:reports:read` + - Allows the client to read reports about content from magazines the user moderates + - `moderate:magazine:reports:action` + - Allows the client to take action on reports, either accepting or rejecting them + - `moderate:magazine:trash:read` + - Allows viewing the removed content of a moderated magazine - `moderate:magazine_admin` - - `moderate:magazine_admin:create` - - Allows the creation of new magazines - - `moderate:magazine_admin:delete` - - Allows the deletion of magazines the user has permission to delete - - `moderate:magazine_admin:update` - - Allows magazine rules, description, settings, title, etc to be updated - - `moderate:magazine_admin:theme` - - Allows updates to the magazine theme - - `moderate:magazine_admin:moderators` - - Allows the addition or removal of moderators to/from an owned magazine - - `moderate:magazine_admin:badges` - - Allows the addition or removal of badges to/from an owned magazine - - `moderate:magazine_admin:tags` - - Allows the addition or removal of tags to/from an owned magazine - - `moderate:magazine_admin:stats` - - Allows the client to view stats from an owned magazine -16. `admin` - All scopes require the instance admin role to perform + - `moderate:magazine_admin:create` + - Allows the creation of new magazines + - `moderate:magazine_admin:delete` + - Allows the deletion of magazines the user has permission to delete + - `moderate:magazine_admin:update` + - Allows magazine rules, description, settings, title, etc to be updated + - `moderate:magazine_admin:theme` + - Allows updates to the magazine theme + - `moderate:magazine_admin:moderators` + - Allows the addition or removal of moderators to/from an owned magazine + - `moderate:magazine_admin:badges` + - Allows the addition or removal of badges to/from an owned magazine + - `moderate:magazine_admin:tags` + - Allows the addition or removal of tags to/from an owned magazine + - `moderate:magazine_admin:stats` + - Allows the client to view stats from an owned magazine +17. `admin` - All scopes require the instance admin role to perform - `admin:entry:purge` - - Allows threads to be completely removed from the instance + - Allows threads to be completely removed from the instance - `admin:entry_comment:purge` - - Allows comments in threads to be completely removed from the instance + - Allows comments in threads to be completely removed from the instance - `admin:post:purge` - - Allows posts to be completely removed from the instance + - Allows posts to be completely removed from the instance - `admin:post_comment:purge` - - Allows post comments to be completely removed from the instance + - Allows post comments to be completely removed from the instance - `admin:magazine` - - `admin:magazine:move_entry` - - Allows an admin to move an entry to another magazine - - `admin:magazine:purge` - - Allows an admin to completely purge a magazine from the instance - - `admin:magazine:moderate` - - Allows an admin to accept or reject moderator and ownership requests of magazines + - `admin:magazine:move_entry` + - Allows an admin to move an entry to another magazine + - `admin:magazine:purge` + - Allows an admin to completely purge a magazine from the instance + - `admin:magazine:moderate` + - Allows an admin to accept or reject moderator and ownership requests of magazines - `admin:user` - - `admin:user:ban` - - Allows the admin to ban or unban users from the instance - - `admin:user:verify` - - Allows the admin to verify a user on the instance - - `admin:user:purge` - - Allows the admin to completely purge a user from the instance + - `admin:user:ban` + - Allows the admin to ban or unban users from the instance + - `admin:user:verify` + - Allows the admin to verify a user on the instance + - `admin:user:purge` + - Allows the admin to completely purge a user from the instance - `admin:instance` - - `admin:instance:settings` - - `admin:instance:settings:read` - - Allows the admin to read instance settings - - `admin:instance:settings:edit` - - Allows the admin to update instance settings - - `admin:instance:information:edit` - - Allows the admin to update information on the About, Contact, FAQ, Privacy Policy, and Terms of Service pages. + - `admin:instance:settings` + - `admin:instance:settings:read` + - Allows the admin to read instance settings + - `admin:instance:settings:edit` + - Allows the admin to update instance settings + - `admin:instance:information:edit` + - Allows the admin to update information on the About, Contact, FAQ, Privacy Policy, and Terms of Service + pages. - `admin:federation` - - `admin:federation:read` - - Allows the admin to read a list of defederated instances - - `admin:federation:update` - - Allows the admin to edit the list of defederated instances + - `admin:federation:read` + - Allows the admin to read a list of defederated instances + - `admin:federation:update` + - Allows the admin to edit the list of defederated instances - `admin:oauth_clients` - - `admin:oauth_clients:read` - - Allows the admin to read usage stats of oauth clients, as well as list clients on the instance - - `admin:oauth_clients:revoke` - - Allows the admin to revoke a client's permission to access the instance + - `admin:oauth_clients:read` + - Allows the admin to read usage stats of oauth clients, as well as list clients on the instance + - `admin:oauth_clients:revoke` + - Allows the admin to revoke a client's permission to access the instance diff --git a/src/Controller/Api/Tag/TagBaseApi.php b/src/Controller/Api/Tag/TagBaseApi.php new file mode 100644 index 0000000000..11090bf614 --- /dev/null +++ b/src/Controller/Api/Tag/TagBaseApi.php @@ -0,0 +1,36 @@ +factory = $factory; + } + + #[Required] + public function setRepository(TagRepository $repository): void + { + $this->repository = $repository; + } + + /** + * Serialize a domain to JSON. + */ + protected function serializeHashtag(Hashtag $tag): HashtagResponseDto + { + return $this->factory->createDto($tag); + } +} \ No newline at end of file diff --git a/src/Controller/Api/Tag/TagBlockApi.php b/src/Controller/Api/Tag/TagBlockApi.php new file mode 100644 index 0000000000..e5d1d4d7e2 --- /dev/null +++ b/src/Controller/Api/Tag/TagBlockApi.php @@ -0,0 +1,216 @@ + 'tag'])] + Hashtag $tag, + TagManager $manager, + RateLimiterFactoryInterface $apiUpdateLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiUpdateLimiter); + + $manager->block($this->getUserOrThrow(), $tag); + + return new JsonResponse( + $this->serializeHashtag($tag), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Hashtag unblocked', + content: new Model(type: DomainDto::class), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'Hashtag not found', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Parameter( + name: 'name', + in: 'path', + description: 'The hashtag to unblock', + schema: new OA\Schema(type: 'string'), + )] + #[OA\Tag(name: 'domain')] + #[Security(name: 'oauth2', scopes: ['hashtag:block'])] + #[IsGranted('ROLE_OAUTH2_HASHTAG:BLOCK')] + public function unblock( + #[MapEntity(mapping: ['name' => 'tag'])] + Hashtag $tag, + TagManager $manager, + RateLimiterFactoryInterface $apiUpdateLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiUpdateLimiter); + + $manager->unblock($this->getUserOrThrow(), $tag); + + return new JsonResponse( + $this->serializeHashtag($tag), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Returns a paginated list of blocked hashtags', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: HashtagResponseDto::class)) + ), + new OA\Property( + property: 'pagination', + ref: new Model(type: PaginationSchema::class) + ), + ] + ), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Parameter( + name: 'p', + description: 'Page of hashtags to retrieve', + in: 'query', + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + )] + #[OA\Parameter( + name: 'perPage', + description: 'Number of hashtags per page', + in: 'query', + schema: new OA\Schema(type: 'integer', default: TagRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) + )] + #[OA\Tag(name: 'tag')] + #[Security(name: 'oauth2', scopes: ['hashtag:block'])] + #[IsGranted('ROLE_OAUTH2_HASHTAG:BLOCK')] + public function list( + RateLimiterFactoryInterface $apiReadLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter); + + $request = $this->request->getCurrentRequest(); + $blocks = $this->repository->findBlockedTags( + $this->getPageNb($request), + $this->getUserOrThrow(), + self::constrainPerPage($request->get('perPage', TagRepository::PER_PAGE)) + ); + + $dtos = []; + foreach ($blocks->getCurrentPageResults() as $value) { + \assert($value instanceof HashtagBlock); + $dtos[] = $this->serializeHashtag($value->hashtag); + } + + return new JsonResponse( + $this->serializePaginated($dtos, $blocks), + headers: $headers + ); + } +} diff --git a/src/DTO/HashtagResponseDto.php b/src/DTO/HashtagResponseDto.php new file mode 100644 index 0000000000..0ba3d941bf --- /dev/null +++ b/src/DTO/HashtagResponseDto.php @@ -0,0 +1,47 @@ +tag = $tag; + $toReturn->entryCount = $entryCount; + $toReturn->entryCommentCount = $entryCommentCount; + $toReturn->postCount = $postCount; + $toReturn->postCommentCount = $postCommentCount; + $toReturn->isBlockedByUser = null; + + return $toReturn; + } + + public function jsonSerialize(): mixed + { + return [ + 'tag' => $this->tag, + 'entryCount' => $this->entryCount, + 'entryCommentCount' => $this->entryCommentCount, + 'postCount' => $this->postCount, + 'postCommentCount' => $this->postCommentCount, + 'isBlockedByUser' => $this->isBlockedByUser, + ]; + } +} \ No newline at end of file diff --git a/src/DTO/OAuth2ClientDto.php b/src/DTO/OAuth2ClientDto.php index d4f80d8a9c..171c03f856 100644 --- a/src/DTO/OAuth2ClientDto.php +++ b/src/DTO/OAuth2ClientDto.php @@ -31,6 +31,8 @@ class OAuth2ClientDto extends ImageUploadDto implements \JsonSerializable 'domain', 'domain:subscribe', 'domain:block', + 'hashtag', + 'hashtag:block', 'entry', 'entry:create', 'entry:edit', diff --git a/src/Entity/OAuth2UserConsent.php b/src/Entity/OAuth2UserConsent.php index f1ebe4ad04..36679600e0 100644 --- a/src/Entity/OAuth2UserConsent.php +++ b/src/Entity/OAuth2UserConsent.php @@ -38,6 +38,10 @@ class OAuth2UserConsent 'domain' => 'oauth2.grant.domain.all', 'domain:subscribe' => 'oauth2.grant.domain.subscribe', 'domain:block' => 'oauth2.grant.domain.block', + // Grants allowing applications to (un)subscribe or (un)block hashtags on behalf of the user + 'hashtag' => 'oauth2.grant.hashtag.all', + //'hashtag:subscribe' => 'oauth2.grant.hashtag.subscribe', + 'hashtag:block' => 'oauth2.grant.hashtag.block', // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report entries on behalf of the user 'entry' => 'oauth2.grant.entry.all', 'entry:create' => 'oauth2.grant.entry.create', diff --git a/src/Factory/HashtagFactory.php b/src/Factory/HashtagFactory.php new file mode 100644 index 0000000000..c1f42e766c --- /dev/null +++ b/src/Factory/HashtagFactory.php @@ -0,0 +1,45 @@ +tagRepository->getCounts($tag->tag); + $dto = HashtagResponseDto::create( + $tag->tag, + $counts['entry'], + $counts['entry_comment'], + $counts['post'], + $counts['post_comment'], + ); + + /** @var User $currentUser */ + $currentUser = $this->security->getUser(); + if($currentUser instanceof User) { + // Only return the user's settings if permission to control settings has been given + $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_HASHTAG:BLOCK') ? $currentUser->isBlockedHashtag($tag) : null; + } + + return $dto; + } +} diff --git a/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php b/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php new file mode 100644 index 0000000000..fa98ced752 --- /dev/null +++ b/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php @@ -0,0 +1,206 @@ +getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + $this->client->request('PUT', "/api/tag/sometag/block"); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotBlockHashtagWithoutScope() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/sometag/block", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + #[Group(name: 'NonThreadSafe')] + public function testApiCanBlockHashtag() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/sometag/block", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertTrue($jsonData['isBlockedByUser']); + + // Idempotent when called multiple times + $this->client->request('PUT', "/api/tag/sometag/block", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertTrue($jsonData['isBlockedByUser']); + } + + public function testApiCannotUnblockHashtagAnonymous() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + $this->client->request('PUT', "/api/tag/sometag/unblock"); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotUnblockHashtagWithoutScope() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/sometag/unblock", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + #[Group(name: 'NonThreadSafe')] + public function testApiCanUnblockHashtag() + { + $user = $this->getUserByUsername('JohnDoe'); + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + $this->tagManager->block($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/sometag/unblock", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertFalse($jsonData['isBlockedByUser']); + + // Idempotent when called multiple times + $this->client->request('PUT', "/api/tag/sometag/unblock", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertFalse($jsonData['isBlockedByUser']); + } + + public function testApiCannotRetrieveBlockedHashtagsAnonymous() + { + $this->client->request('GET', '/api/tags/blocked'); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotRetrieveBlockedHashtagWithoutScope() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + $user = $this->getUserByUsername('JohnDoe'); + $this->tagManager->block($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('GET', '/api/tags/blocked', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + public function testApiCanRetrieveBlockedHashtags() + { + $this->getEntryByTitle('testApiCanRetrieveBlockedHashtags', body: 'some text with #tag1 #tag2 #tag3'); + $user = $this->getUserByUsername('JohnDoe'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/tag1/block", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + $this->client->request('PUT', "/api/tag/tag2/block", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->client->request('GET', '/api/tags/blocked', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); + + self::assertIsArray($jsonData['pagination']); + self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); + + $blocked = $jsonData['items']; + self::assertIsArray($blocked); + self::assertCount(2, $blocked); + + $tag1Found = false; + $tag2Found = false; + foreach ($blocked as $block) { + self::assertIsArray($block); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $block); + self::assertSame(1, $block['entryCount']); + self::assertSame(0, $block['entryCommentCount']); + self::assertSame(0, $block['postCount']); + self::assertSame(0, $block['postCommentCount']); + self::assertTrue($block['isBlockedByUser']); + + $tag1Found = ($tag1Found or $block['tag'] === 'tag1'); + $tag2Found = ($tag2Found or $block['tag'] === 'tag2'); + } + self::assertTrue($tag1Found); + self::assertTrue($tag2Found); + } +} diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 9d4d3dacab..94007e3d95 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -105,6 +105,7 @@ abstract class WebTestCase extends BaseWebTestCase protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'banner', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus', 'discoverable', 'indexable']; protected const MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'banner', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId', 'discoverable', 'indexable']; protected const DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser']; + protected const array HASHTAG_RESPONSE_KEYS = [ 'tag', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isBlockedByUser' ]; protected const KIBBY_PNG_URL_RESULT = 'a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png'; From 2e97afe4d4aa4fd4326e83e26f72b87faad4b375 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sun, 26 Jul 2026 18:40:49 +0000 Subject: [PATCH 05/16] implement hashtag subscriptions --- migrations/Version20260726182822.php | 35 ++++++ src/Entity/Hashtag.php | 47 +++++++ src/Entity/HashtagSubscription.php | 49 ++++++++ src/Entity/User.php | 3 + src/Event/HashtagSubscriptionChangedEvent.php | 16 +++ .../Hashtag/HashtagFollowSubscriber.php | 28 +++++ src/Repository/ContentRepository.php | 43 +++++-- src/Repository/Criteria.php | 4 + src/Repository/TagRepository.php | 18 +++ src/Service/TagManager.php | 23 ++++ src/Utils/SqlHelpers.php | 32 +++++ .../Service/Hashtag/TagBlockTest.php | 40 +++++- .../Service/Hashtag/TagSubscriptionTest.php | 115 ++++++++++++++++++ 13 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 migrations/Version20260726182822.php create mode 100644 src/Entity/HashtagSubscription.php create mode 100644 src/Event/HashtagSubscriptionChangedEvent.php create mode 100644 src/EventSubscriber/Hashtag/HashtagFollowSubscriber.php create mode 100644 tests/Functional/Service/Hashtag/TagSubscriptionTest.php diff --git a/migrations/Version20260726182822.php b/migrations/Version20260726182822.php new file mode 100644 index 0000000000..7ac3d7bdc4 --- /dev/null +++ b/migrations/Version20260726182822.php @@ -0,0 +1,35 @@ +addSql('CREATE SEQUENCE hashtag_subscription_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE hashtag_subscription (id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, user_id INT NOT NULL, hashtag_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_5814F278A76ED395 ON hashtag_subscription (user_id)'); + $this->addSql('CREATE INDEX IDX_5814F278FB34EF56 ON hashtag_subscription (hashtag_id)'); + $this->addSql('CREATE UNIQUE INDEX hashtag_subscription_idx ON hashtag_subscription (user_id, hashtag_id)'); + $this->addSql('ALTER TABLE hashtag_subscription ADD CONSTRAINT FK_5814F278A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE hashtag_subscription ADD CONSTRAINT FK_5814F278FB34EF56 FOREIGN KEY (hashtag_id) REFERENCES hashtag (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE hashtag_subscription_id_seq CASCADE'); + $this->addSql('ALTER TABLE hashtag_subscription DROP CONSTRAINT FK_5814F278A76ED395'); + $this->addSql('ALTER TABLE hashtag_subscription DROP CONSTRAINT FK_5814F278FB34EF56'); + $this->addSql('DROP TABLE hashtag_subscription'); + } +} diff --git a/src/Entity/Hashtag.php b/src/Entity/Hashtag.php index 64b4756c4d..f7a802e367 100644 --- a/src/Entity/Hashtag.php +++ b/src/Entity/Hashtag.php @@ -5,7 +5,9 @@ namespace App\Entity; use App\Repository\TagRepository; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\GeneratedValue; @@ -25,6 +27,51 @@ class Hashtag #[Column(type: 'boolean', options: ['default' => false])] public bool $banned = false; + #[OneToMany(mappedBy: 'hashtag', targetEntity: HashtagSubscription::class, fetch: 'EXTRA_LAZY', cascade: [ + 'persist', + 'remove', + ], orphanRemoval: true)] + public Collection $subscriptions; + #[OneToMany(mappedBy: 'hashtag', targetEntity: HashtagLink::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $linkedPosts; + + public function __construct() + { + $this->subscriptions = new ArrayCollection(); + $this->linkedPosts = new ArrayCollection(); + } + + public function subscribe(User $user): void + { + if (!$this->isSubscribed($user)) { + $subscription = new HashtagSubscription($user, $this); + $this->subscriptions->add($subscription); + $user->subscribedHashtags->add($subscription); + } + } + + public function unsubscribe(User $user): void + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)); + + /** @var HashtagSubscription $subscription */ + $subscription = $this->subscriptions->matching($criteria)->first(); + + if ($this->subscriptions->removeElement($subscription)) { + if ($subscription->hashtag === $this) { + $subscription->hashtag = null; + } + $user->subscribedHashtags->removeElement($subscription); + } + } + + public function isSubscribed(User $user): bool + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)); + + return !$this->subscriptions->matching($criteria)->isEmpty(); + } } diff --git a/src/Entity/HashtagSubscription.php b/src/Entity/HashtagSubscription.php new file mode 100644 index 0000000000..aeb79d8aae --- /dev/null +++ b/src/Entity/HashtagSubscription.php @@ -0,0 +1,49 @@ +createdAtTraitConstruct(); + + $this->user = $user; + $this->hashtag = $hashtag; + } + + public function getId(): ?int + { + return $this->id; + } +} \ No newline at end of file diff --git a/src/Entity/User.php b/src/Entity/User.php index a12782b5d6..1de74e73f5 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -214,6 +214,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public Collection $subscriptions; #[OneToMany(mappedBy: 'user', targetEntity: DomainSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $subscribedDomains; + #[OneToMany(mappedBy: 'user', targetEntity: HashtagSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + public Collection $subscribedHashtags; #[OneToMany(mappedBy: 'follower', targetEntity: UserFollow::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $follows; @@ -310,6 +312,7 @@ public function __construct( $this->postCommentVotes = new ArrayCollection(); $this->subscriptions = new ArrayCollection(); $this->subscribedDomains = new ArrayCollection(); + $this->subscribedHashtags = new ArrayCollection(); $this->follows = new ArrayCollection(); $this->followers = new ArrayCollection(); $this->blocks = new ArrayCollection(); diff --git a/src/Event/HashtagSubscriptionChangedEvent.php b/src/Event/HashtagSubscriptionChangedEvent.php new file mode 100644 index 0000000000..4c8cf4c95a --- /dev/null +++ b/src/Event/HashtagSubscriptionChangedEvent.php @@ -0,0 +1,16 @@ + 'handleHashtagSubscriptionChangedEvent']; + } + + public function handleHashtagSubscriptionChangedEvent(HashtagSubscriptionChangedEvent $event): void + { + $this->sqlHelpers->clearCachedUserSubscribedHashtags($event->user); + } +} diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index e150184b13..830870a768 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -196,7 +196,18 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use $subClauseEntryComment = ''; $subClausePostComment = ''; if ($user && $criteria->subscribed) { + $clauseFragmentHashtag = ''; + // only include the subclause if there are (/ might be) subscriptions + if($criteria->cachedUserSubscribedHashtags === null || !empty($criteria->cachedUserSubscribedHashtags)) { + if($criteria->cachedUserSubscribedHashtags === null) { + $clauseFragmentHashtag = ' OR EXISTS (SELECT 1 FROM hashtag_subscription hs INNER JOIN hashtag_link hl ON hs.hashtag_id = hl.hashtag_id WHERE hs.user_id = :loggedInUser AND hl.%hl_type%_id = c.id)'; + } else { + $clauseFragmentHashtag = ' OR EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.%hl_type%_id = c.id AND hl.hashtag_id IN (:cachedUserSubscribedHashtags))'; + } + } + $subClausePost = 'c.user_id = :loggedInUser' + .$clauseFragmentHashtag .(null === $criteria->cachedUserSubscribedMagazines ? ' OR EXISTS (SELECT 1 FROM magazine_subscription ms WHERE ms.user_id = :loggedInUser AND ms.magazine_id = c.magazine_id)' : ' OR c.magazine_id IN (:cachedUserSubscribedMagazines)') @@ -208,7 +219,11 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use ' OR EXISTS (SELECT 1 FROM domain_subscription ds WHERE ds.domain_id = c.domain_id AND ds.user_id = :loggedInUser)' : ' OR c.domain_id IN (:cachedUserSubscribedDomains)'); + $subClausePost = str_replace('%hl_type%', 'post', $subClausePost); + $subClauseEntry = str_replace('%hl_type%', 'entry', $subClauseEntry); + if ($criteria->includeBoosts) { + //TODO should comments with a subscribed hashtag be included too? $repliesCommonWhere = 'c.user_id = :loggedInUser' .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' : @@ -239,6 +254,9 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use if (null !== $criteria->cachedUserSubscribedDomains && $includeEntries) { $parameters['cachedUserSubscribedDomains'] = $criteria->cachedUserSubscribedDomains; } + if (null !== $criteria->cachedUserSubscribedHashtags) { + $parameters['cachedUserSubscribedHashtags'] = $criteria->cachedUserSubscribedHashtags; + } } $modClause = ''; @@ -303,18 +321,21 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use $blockingClauseEntryComment = $blockingClausePost; $blockingClausePostComment = $blockingClausePost; - if(null == $criteria->cachedUserBlockedHashtags) { - $blockingClauseEntry = $blockingClauseEntry.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_id = c.id AND hb.user_id = :loggedInUser)'; - $blockingClausePost = $blockingClausePost.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_id = c.id AND hb.user_id = :loggedInUser)'; - $blockingClauseEntryComment = $blockingClauseEntryComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_comment_id = c.id AND hb.user_id = :loggedInUser)'; - $blockingClausePostComment = $blockingClausePostComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_comment_id = c.id AND hb.user_id = :loggedInUser)'; - } else { - $blockingClauseEntry = $blockingClauseEntry.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; - $blockingClausePost = $blockingClausePost.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; - $blockingClauseEntryComment = $blockingClauseEntryComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; - $blockingClausePostComment = $blockingClausePostComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + // only include the subcluase if there are (/ might be) blocks + if(null == $criteria->cachedUserBlockedHashtags || !empty($criteria->cachedUserBlockedHashtags)) { + if (null == $criteria->cachedUserBlockedHashtags) { + $blockingClauseEntry = $blockingClauseEntry . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClausePost = $blockingClausePost . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClauseEntryComment = $blockingClauseEntryComment . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_comment_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClausePostComment = $blockingClausePostComment . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_comment_id = c.id AND hb.user_id = :loggedInUser)'; + } else { + $blockingClauseEntry = $blockingClauseEntry . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClausePost = $blockingClausePost . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClauseEntryComment = $blockingClauseEntryComment . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClausePostComment = $blockingClausePostComment . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; - $parameters['cachedUserBlockedHashtags'] = $criteria->cachedUserBlockedHashtags; + $parameters['cachedUserBlockedHashtags'] = $criteria->cachedUserBlockedHashtags; + } } } diff --git a/src/Repository/Criteria.php b/src/Repository/Criteria.php index d0edbecdd9..b4e2611d1b 100644 --- a/src/Repository/Criteria.php +++ b/src/Repository/Criteria.php @@ -112,6 +112,9 @@ abstract class Criteria /** @var int[]|null */ public ?array $cachedUserSubscribedDomains = null; + /** @var int[]|null */ + public ?array $cachedUserSubscribedHashtags = null; + /** @var int[]|null */ public ?array $cachedUserBlocks = null; @@ -354,6 +357,7 @@ public function fetchCachedItems(SqlHelpers $sqlHelpers, User $loggedInUser): vo if ($this->subscribed) { $this->cachedUserSubscribedDomains = $sqlHelpers->getCachedUserSubscribedDomains($loggedInUser); $this->cachedUserSubscribedMagazines = $sqlHelpers->getCachedUserSubscribedMagazines($loggedInUser); + $this->cachedUserSubscribedHashtags = $sqlHelpers->getCachedUserSubscribedHashtags($loggedInUser); } if ($this->moderated) { diff --git a/src/Repository/TagRepository.php b/src/Repository/TagRepository.php index 9b66167a5d..fbe5b4a353 100644 --- a/src/Repository/TagRepository.php +++ b/src/Repository/TagRepository.php @@ -119,6 +119,24 @@ public function getCounts(string $tag): ?array ]; } + public function findSubscribedTags(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta + { + $pagerfanta = new Pagerfanta( + new CollectionAdapter( + $user->subscribedHashtags + ) + ); + + try { + $pagerfanta->setMaxPerPage($perPage); + $pagerfanta->setCurrentPage($page); + } catch (NotValidCurrentPageException $e) { + throw new NotFoundHttpException(); + } + + return $pagerfanta; + } + public function findBlockedTags(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta { $pagerfanta = new Pagerfanta( diff --git a/src/Service/TagManager.php b/src/Service/TagManager.php index 474ae5f4d7..6bb569c1a1 100644 --- a/src/Service/TagManager.php +++ b/src/Service/TagManager.php @@ -6,13 +6,16 @@ use App\DTO\EntryCommentDto; use App\DTO\EntryDto; +use App\Entity\Domain; use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\Hashtag; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; +use App\Event\DomainSubscribedEvent; use App\Event\HashtagBlockChangedEvent; +use App\Event\HashtagSubscriptionChangedEvent; use App\Repository\TagLinkRepository; use App\Repository\TagRepository; use Doctrine\ORM\EntityManagerInterface; @@ -182,6 +185,26 @@ public function isAnyTagBanned(?array $tags): bool return false; } + public function subscribe(User $user, Hashtag $tag): void + { + $user->unblockHashtag($tag); + + $tag->subscribe($user); + + $this->entityManager->flush(); + + $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($tag, $user, true)); + } + + public function unsubscribe(User $user, Hashtag $tag): void + { + $tag->unsubscribe($user); + + $this->entityManager->flush(); + + $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($tag, $user, false)); + } + public function block(User $user, Hashtag $hashtag): void { $user->blockHashtag($hashtag); $this->entityManager->flush(); diff --git a/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php index dadbc814d9..f5d3ffb0a4 100644 --- a/src/Utils/SqlHelpers.php +++ b/src/Utils/SqlHelpers.php @@ -24,6 +24,7 @@ class SqlHelpers public const string USER_MAGAZINE_SUBSCRIPTION_KEY = 'cached_user_magazine_subscription_'; public const string USER_MAGAZINE_MODERATION_KEY = 'cached_user_magazine_moderation_'; public const string USER_DOMAIN_SUBSCRIPTION_KEY = 'cached_user_domain_subscription_'; + public const string USER_HASHTAG_SUBSCRIPTION_KEY = 'cached_user_hashtag_subscription_'; public const string USER_BLOCKS_KEY = 'cached_user_blocks_'; public const string USER_MAGAZINE_BLOCKS_KEY = 'cached_user_magazine_block_'; public const string USER_DOMAIN_BLOCKS_KEY = 'cached_user_domain_block_'; @@ -274,6 +275,37 @@ public function clearCachedUserSubscribedDomains(User $user): void } } + /** + * @return int[] the ids of the hashtags $user is subscribed to + */ + public function getCachedUserSubscribedHashtags(User $user): array + { + try { + $sql = 'SELECT hashtag_id FROM hashtag_subscription WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_HASHTAG_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } catch (InvalidArgumentException|Exception $exception) { + $this->logger->error('There was an error getting the cached subscribed hashtags of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + + return []; + } + } + + public function clearCachedUserSubscribedHashtags(User $user): void + { + $this->logger->debug('Clearing cached hashtag subscriptions for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_HASHTAG_SUBSCRIPTION_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached subscribed hashtags of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + /** * @return int[] the ids of the users $user has blocked */ diff --git a/tests/Functional/Service/Hashtag/TagBlockTest.php b/tests/Functional/Service/Hashtag/TagBlockTest.php index aa81094323..cacfce595d 100644 --- a/tests/Functional/Service/Hashtag/TagBlockTest.php +++ b/tests/Functional/Service/Hashtag/TagBlockTest.php @@ -42,7 +42,7 @@ public function testUnblock() { self::assertSame($tag1->tag, $user2->blockedHashtags->first()->hashtag->tag); } - public function testBlockedHashtagIsHiddenInCombined() { + public function testBlockedHashtagIsHiddenInCombinedWithCache() { $user = $this->getUserByUsername('John Doe'); $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('notWanted'); @@ -81,6 +81,44 @@ public function testBlockedHashtagIsHiddenInCombined() { self::assertCount(4, $result); } + public function testBlockedHashtagIsHiddenInCombinedWithoutCache() { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('notWanted'); + + $magazine = $this->getMagazineByName('HashtagBlockTest'); + $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #wanted'); + $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notWanted'); + usleep(10000); + $entryCommentShowing = $this->createEntryComment('some text #wanted', $entryShowing, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notWanted', $entryShowing, $contentCreator); + usleep(10000); + $postShowing = $this->createPost('some text #wanted', $magazine, $contentCreator); + $postHidden = $this->createPost('some text #notWanted', $magazine, $contentCreator); + usleep(10000); + $postCommentShowing = $this->createPostComment('some text #wanted', $postShowing, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notWanted', $postShowing, $contentCreator); + + $user->follow($contentCreator); + $this->tagManager->block($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_NEW); + $criteria->magazine = $magazine; + $criteria->includeBoosts = true; + $criteria->perPage = 5; + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertSame($entryCommentShowing->getId(), $result[1]->getId()); + self::assertSame($postShowing->getId(), $result[2]->getId()); + self::assertSame($postCommentShowing->getId(), $result[3]->getId()); + self::assertCount(4, $result); + } + public function testBlockedHashtagIsHiddenInEntryComments() { $user = $this->getUserByUsername('John Doe'); diff --git a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php new file mode 100644 index 0000000000..488b7df2ff --- /dev/null +++ b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php @@ -0,0 +1,115 @@ +getUserByUsername('John Doe'); + $user2 = $this->getUserByUsername('Jane Doe'); + $tagNeutral = $this->getHashtag('abc'); + $tagBlocked = $this->getHashtag('def'); + + $this->tagManager->subscribe($user1, $tagBlocked); + + self::assertCount(1, $user1->subscribedHashtags); + self::assertSame($tagBlocked->tag, $user1->subscribedHashtags[0]->hashtag->tag); + self::assertCount(0, $user2->subscribedHashtags); + } + + public function testUnsubscribe() { + $user1 = $this->getUserByUsername('John Doe'); + $user2 = $this->getUserByUsername('Jane Doe'); + $tag1 = $this->getHashtag('abc'); + $tag2 = $this->getHashtag('def'); + + $this->tagManager->subscribe($user1, $tag1); + $this->tagManager->subscribe($user1, $tag2); + $this->tagManager->subscribe($user2, $tag1); + + $this->tagManager->unsubscribe($user1, $tag1); + + self::assertCount(1, $user1->subscribedHashtags); + self::assertSame($tag2->tag, $user1->subscribedHashtags->first()->hashtag->tag); + self::assertCount(1, $user2->subscribedHashtags); + self::assertSame($tag1->tag, $user2->subscribedHashtags->first()->hashtag->tag); + } + + public function testSubscribedHashtagIsIncludedInCombinedWithCache() { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('interesting'); + + $magazine = $this->getMagazineByName('TagSubscriptionTest'); + $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); + $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); + usleep(10000); + $entryCommentShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); + usleep(10000); + $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); + $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); + usleep(10000); + $postCommentShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); + + $this->tagManager->subscribe($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_NEW); + $criteria->subscribed = true; + $criteria->includeBoosts = false; + $criteria->perPage = 5; + $criteria->fetchCachedItems($this->sqlHelpers, $user); + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertSame($postShowing->getId(), $result[1]->getId()); + self::assertCount(2, $result); + } + + public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('interesting'); + + $magazine = $this->getMagazineByName('TagSubscriptionTest'); + $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); + $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); + usleep(10000); + $entryCommentShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); + usleep(10000); + $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); + $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); + usleep(10000); + $postCommentShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); + + $this->tagManager->subscribe($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_NEW); + $criteria->subscribed = true; + $criteria->includeBoosts = false; + $criteria->perPage = 5; + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertSame($postShowing->getId(), $result[1]->getId()); + self::assertCount(2, $result); + } +} \ No newline at end of file From 42f5747d59fff6af501617683eaff909fbf4dadc Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sun, 26 Jul 2026 20:07:14 +0000 Subject: [PATCH 06/16] add subscribe button and list of subscribed hashtags in user profile page --- config/mbin_routes/tag.yaml | 10 +++ config/mbin_routes/user.yaml | 5 ++ .../Tag/TagSubscriptionController.php | 65 +++++++++++++++++++ .../User/Profile/UserSubController.php | 15 +++++ src/Repository/ContentRepository.php | 4 +- src/Twig/Components/HashtagSubComponent.php | 6 ++ templates/components/hashtag_sub.html.twig | 17 +++-- .../user/settings/sub_hashtags.html.twig | 21 ++++++ templates/user/settings/sub_pills.html.twig | 6 ++ .../User/Profile/UserSubControllerTest.php | 15 +++++ 10 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 src/Controller/Tag/TagSubscriptionController.php create mode 100644 templates/user/settings/sub_hashtags.html.twig diff --git a/config/mbin_routes/tag.yaml b/config/mbin_routes/tag.yaml index 5e5d3c39b1..47a4078e7c 100644 --- a/config/mbin_routes/tag.yaml +++ b/config/mbin_routes/tag.yaml @@ -50,3 +50,13 @@ tag_unblock: controller: App\Controller\Tag\TagBlockController::unblock path: /tag/{name}/unblock methods: [ POST ] + +tag_subscribe: + controller: App\Controller\Tag\TagSubscriptionController::subscribe + path: /tag/{name}/subscribe + methods: [ POST ] + +tag_unsubscribe: + controller: App\Controller\Tag\TagSubscriptionController::unsubscribe + path: /tag/{name}/unsubscribe + methods: [ POST ] diff --git a/config/mbin_routes/user.yaml b/config/mbin_routes/user.yaml index cecb695ef4..162c553315 100644 --- a/config/mbin_routes/user.yaml +++ b/config/mbin_routes/user.yaml @@ -180,6 +180,11 @@ user_settings_domain_subscriptions: path: /settings/subscriptions/domains methods: [GET] +user_settings_hashtag_subscriptions: + controller: App\Controller\User\Profile\UserSubController::hashtags + path: /settings/subscriptions/hashtags + methods: [GET] + user_settings_user_subscriptions: controller: App\Controller\User\Profile\UserSubController::users path: /settings/subscriptions/people diff --git a/src/Controller/Tag/TagSubscriptionController.php b/src/Controller/Tag/TagSubscriptionController.php new file mode 100644 index 0000000000..4a61f9ba11 --- /dev/null +++ b/src/Controller/Tag/TagSubscriptionController.php @@ -0,0 +1,65 @@ + 'tag'])] Hashtag $tag, Request $request): Response + { + $this->manager->subscribe($this->getUserOrThrow(), $tag); + + if ($request->isXmlHttpRequest()) { + return $this->getJsonResponse($tag); + } + + return $this->redirectToRefererOrHome($request); + } + + #[IsGranted('ROLE_USER')] + public function unsubscribe(#[MapEntity(mapping: ['name' => 'tag'])] Hashtag $tag, Request $request): Response + { + $this->manager->unsubscribe($this->getUserOrThrow(), $tag); + + if ($request->isXmlHttpRequest()) { + return $this->getJsonResponse($tag); + } + + return $this->redirectToRefererOrHome($request); + } + + private function getJsonResponse(Hashtag $tag): JsonResponse + { + return new JsonResponse( + [ + 'html' => $this->renderView( + 'components/_ajax.html.twig', + [ + 'component' => 'hashtag_sub', + 'attributes' => [ + 'hashtag' => $tag, + ], + ] + ), + ] + ); + } +} diff --git a/src/Controller/User/Profile/UserSubController.php b/src/Controller/User/Profile/UserSubController.php index b5b80d99db..a458e44171 100644 --- a/src/Controller/User/Profile/UserSubController.php +++ b/src/Controller/User/Profile/UserSubController.php @@ -7,6 +7,7 @@ use App\Controller\AbstractController; use App\Repository\DomainRepository; use App\Repository\MagazineRepository; +use App\Repository\TagRepository; use App\Repository\UserRepository; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -58,4 +59,18 @@ public function domains(DomainRepository $repository, Request $request): Respons ] ); } + + #[IsGranted('ROLE_USER')] + public function hashtags(TagRepository $repository, Request $request): Response + { + $user = $this->getUserOrThrow(); + + return $this->render( + 'user/settings/sub_hashtags.html.twig', + [ + 'user' => $user, + 'hashtags' => $repository->findSubscribedTags($this->getPageNb($request), $user), + ] + ); + } } diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index 830870a768..40ae2ca64d 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -203,6 +203,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use $clauseFragmentHashtag = ' OR EXISTS (SELECT 1 FROM hashtag_subscription hs INNER JOIN hashtag_link hl ON hs.hashtag_id = hl.hashtag_id WHERE hs.user_id = :loggedInUser AND hl.%hl_type%_id = c.id)'; } else { $clauseFragmentHashtag = ' OR EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.%hl_type%_id = c.id AND hl.hashtag_id IN (:cachedUserSubscribedHashtags))'; + $parameters['cachedUserSubscribedHashtags'] = $criteria->cachedUserSubscribedHashtags; } } @@ -254,9 +255,6 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use if (null !== $criteria->cachedUserSubscribedDomains && $includeEntries) { $parameters['cachedUserSubscribedDomains'] = $criteria->cachedUserSubscribedDomains; } - if (null !== $criteria->cachedUserSubscribedHashtags) { - $parameters['cachedUserSubscribedHashtags'] = $criteria->cachedUserSubscribedHashtags; - } } $modClause = ''; diff --git a/src/Twig/Components/HashtagSubComponent.php b/src/Twig/Components/HashtagSubComponent.php index fdeb309eae..4e0a19d61e 100644 --- a/src/Twig/Components/HashtagSubComponent.php +++ b/src/Twig/Components/HashtagSubComponent.php @@ -17,7 +17,9 @@ final class HashtagSubComponent { public Hashtag $hashtag; + public bool $isHashtagSubscribed; public bool $isHashtagBlocked; + public int $hashtagSubscriptionCount; public function __construct( private readonly Security $security, @@ -28,9 +30,13 @@ public function postMount(): void { $user = $this->security->getUser(); if ($user instanceof User) { + $this->isHashtagSubscribed = $this->hashtag->isSubscribed($user); $this->isHashtagBlocked = $user->isBlockedHashtag($this->hashtag); } else { + $this->isHashtagSubscribed = false; $this->isHashtagBlocked = false; } + + $this->hashtagSubscriptionCount = $this->hashtag->subscriptions->count(); } } diff --git a/templates/components/hashtag_sub.html.twig b/templates/components/hashtag_sub.html.twig index 7e9394c6ce..47ffa528e1 100644 --- a/templates/components/hashtag_sub.html.twig +++ b/templates/components/hashtag_sub.html.twig @@ -1,20 +1,19 @@ - - {# +
    - {{ domain.subscriptionsCount }} + title="{{ hashtagSubscriptionCount ~ ' ' ~ 'subscribers_count'|trans({'%count%': hashtagSubscriptionCount}) }}" + aria-label="{{ hashtagSubscriptionCount ~ ' ' ~ 'subscribers_count'|trans({'%count%': hashtagSubscriptionCount}) }}"> + {{ hashtagSubscriptionCount }}
    -
    - #} +
    diff --git a/templates/user/settings/sub_hashtags.html.twig b/templates/user/settings/sub_hashtags.html.twig new file mode 100644 index 0000000000..9379d8f924 --- /dev/null +++ b/templates/user/settings/sub_hashtags.html.twig @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'subscriptions'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} +{%- endblock -%} + + +{% block mainClass %}page-settings page-settings-sub-magazines{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} + {% include 'user/settings/_options.html.twig' %} + {% include('user/_visibility_info.html.twig') %} + {% include 'user/settings/sub_pills.html.twig' %} + {% include 'layout/_hashtag_activity_list.html.twig' with {list: hashtags} %} +{% endblock %} diff --git a/templates/user/settings/sub_pills.html.twig b/templates/user/settings/sub_pills.html.twig index d5098cb68a..3ce34178f6 100644 --- a/templates/user/settings/sub_pills.html.twig +++ b/templates/user/settings/sub_pills.html.twig @@ -12,6 +12,12 @@ {{ 'people'|trans }}
  • +
  • + + {{ 'hashtags'|trans }} + +
  • diff --git a/tests/Functional/Controller/User/Profile/UserSubControllerTest.php b/tests/Functional/Controller/User/Profile/UserSubControllerTest.php index fe14fdfe00..9c4e458cee 100644 --- a/tests/Functional/Controller/User/Profile/UserSubControllerTest.php +++ b/tests/Functional/Controller/User/Profile/UserSubControllerTest.php @@ -49,4 +49,19 @@ public function testUserCanSeeSubscribedDomains() $this->assertSelectorTextContains('#main .pills .active', 'Domains'); $this->assertSelectorTextContains('#main', 'kbin.pub'); } + + public function testUserCanSeeSubscribedHashtags() + { + $this->client->loginUser($user = $this->getUserByUsername('JaneDoe')); + + $entry = $this->getEntryByTitle('testUserCanSeeSubscribedHashtags', body: 'body with #sometag'); + + $this->tagManager->subscribe($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + $crawler = $this->client->request('GET', '/settings/subscriptions/hashtags'); + $this->client->click($crawler->filter('#main .pills')->selectLink('Hashtags')->link()); + + $this->assertSelectorTextContains('#main .pills .active', 'Hashtags'); + $this->assertSelectorTextContains('#main', 'sometag'); + } } From 80addf75100d42d2c37f1336d50e3a245a7abc90 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sun, 26 Jul 2026 20:41:51 +0000 Subject: [PATCH 07/16] add API for tag subscriptions --- config/mbin_routes/tag_api.yaml | 24 +- config/packages/league_oauth2_server.yaml | 1 + config/packages/nelmio_api_doc.yaml | 2 + config/packages/security.yaml | 3 +- docs/04-app_developers/README.md | 3 + ...BlockApi.php => TagBlockApiController.php} | 4 +- .../Api/Tag/TagSubscriptionApiController.php | 217 ++++++++++++++++++ src/DTO/HashtagResponseDto.php | 2 + src/DTO/OAuth2ClientDto.php | 1 + src/Entity/OAuth2UserConsent.php | 2 +- src/Factory/HashtagFactory.php | 1 + .../Controller/Api/Tag/TagBlockApiTest.php | 5 + .../Api/Tag/TagSubscribeApiTest.php | 211 +++++++++++++++++ tests/WebTestCase.php | 2 +- 14 files changed, 470 insertions(+), 8 deletions(-) rename src/Controller/Api/Tag/{TagBlockApi.php => TagBlockApiController.php} (99%) create mode 100644 src/Controller/Api/Tag/TagSubscriptionApiController.php create mode 100644 tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php diff --git a/config/mbin_routes/tag_api.yaml b/config/mbin_routes/tag_api.yaml index ca256bf350..a36fb79e00 100644 --- a/config/mbin_routes/tag_api.yaml +++ b/config/mbin_routes/tag_api.yaml @@ -23,19 +23,37 @@ api_tag_post_comments: format: json api_tag_block: - controller: App\Controller\Api\Tag\TagBlockApi::block + controller: App\Controller\Api\Tag\TagBlockApiController::block path: /api/tag/{name}/block methods: [ PUT ] format: json api_tag_unblock: - controller: App\Controller\Api\Tag\TagBlockApi::unblock + controller: App\Controller\Api\Tag\TagBlockApiController::unblock path: /api/tag/{name}/unblock methods: [ PUT ] format: json api_tag_blocked: - controller: App\Controller\Api\Tag\TagBlockApi::list + controller: App\Controller\Api\Tag\TagBlockApiController::list path: /api/tags/blocked methods: [ GET ] format: json + +api_tag_subscribe: + controller: App\Controller\Api\Tag\TagSubscriptionApiController::subscribe + path: /api/tag/{name}/subscribe + methods: [ PUT ] + format: json + +api_tag_unsubscribe: + controller: App\Controller\Api\Tag\TagSubscriptionApiController::unsubscribe + path: /api/tag/{name}/unsubscribe + methods: [ PUT ] + format: json + +api_tag_subscribed: + controller: App\Controller\Api\Tag\TagSubscriptionApiController::list + path: /api/tags/subscribed + methods: [ GET ] + format: json diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index 465835930e..356c440689 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -30,6 +30,7 @@ league_oauth2_server: "domain:block", "hashtag", "hashtag:block", + "hashtag:subscribe", "entry", "entry:create", "entry:edit", diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index 1f087315fe..f9472f51e6 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -116,6 +116,7 @@ nelmio_api_doc: domain:block: Block or unblock domains and view the domains you have blocked. hashtag: Block hashtags, and view the hashtags you subscribed to. hashtag:block: Block or unblock hashtags and view the hashtags you have blocked. + hashtag:subscribe: Subscribe or unsubscribe to hashtags and view the hashtags you subscribed to. entry: Create, edit, or delete your threads, and vote, boost, or report any thread. entry:create: Create new threads. entry:edit: Edit your existing threads. @@ -235,6 +236,7 @@ nelmio_api_doc: domain:block: Block or unblock domains and view the domains you have blocked. hashtag: Block hashtags, and view the hashtags you block. hashtag:block: Block or unblock hashtags and view the hashtags you have blocked. + hashtag:subscribe: Subscribe or unsubscribe to hashtags and view the hashtags you subscribed to. entry: Create, edit, or delete your threads, and vote, boost, or report any thread. entry:create: Create new threads. entry:edit: Edit your existing threads. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index bdac4d49cf..eadd451b73 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -186,6 +186,7 @@ security: [ 'ROLE_OAUTH2_DOMAIN:SUBSCRIBE', 'ROLE_OAUTH2_MAGAZINE:SUBSCRIBE', + 'ROLE_OAUTH2_HASHTAG:SUBSCRIBE', 'ROLE_OAUTH2_USER:FOLLOW', ] 'ROLE_OAUTH2_BOOKMARK': @@ -209,7 +210,7 @@ security: ROLE_OAUTH2_DOMAIN: ['ROLE_OAUTH2_DOMAIN:SUBSCRIBE', 'ROLE_OAUTH2_DOMAIN:BLOCK'] ROLE_OAUTH2_HASHTAG: - ['ROLE_OAUTH2_HASHTAG:BLOCK'] + ['ROLE_OAUTH2_HASHTAG:BLOCK', 'ROLE_OAUTH2_HASHTAG:SUBSCRIBE'] ROLE_OAUTH2_ENTRY: [ 'ROLE_OAUTH2_ENTRY:CREATE', diff --git a/docs/04-app_developers/README.md b/docs/04-app_developers/README.md index 8c829133db..8b8a1b97ef 100644 --- a/docs/04-app_developers/README.md +++ b/docs/04-app_developers/README.md @@ -131,6 +131,8 @@ POST /api/client - Allows viewing and editing domain subscriptions - `magazine:subscribe` - Allows viewing and editing magazine subscriptions + - `hashtag:subscribe` + - Allows viewing and editing hashtag subscriptions - `user:follow` - Allows viewing and editing user follows 5. `block` - Provides the following nested scopes @@ -157,6 +159,7 @@ POST /api/client - `domain:block` 9. `hashtag` - Provides all hashtag scopes - `hashtag:block` + - `hashtag:subscribe` 10. `entry` - Provides all entry scopes - `entry:create` - `entry:edit` diff --git a/src/Controller/Api/Tag/TagBlockApi.php b/src/Controller/Api/Tag/TagBlockApiController.php similarity index 99% rename from src/Controller/Api/Tag/TagBlockApi.php rename to src/Controller/Api/Tag/TagBlockApiController.php index e5d1d4d7e2..fb44e1758e 100644 --- a/src/Controller/Api/Tag/TagBlockApi.php +++ b/src/Controller/Api/Tag/TagBlockApiController.php @@ -25,7 +25,7 @@ use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; -class TagBlockApi extends TagBaseApi +class TagBlockApiController extends TagBaseApi { #[OA\Response( response: 200, @@ -118,7 +118,7 @@ public function block( description: 'The hashtag to unblock', schema: new OA\Schema(type: 'string'), )] - #[OA\Tag(name: 'domain')] + #[OA\Tag(name: 'tag')] #[Security(name: 'oauth2', scopes: ['hashtag:block'])] #[IsGranted('ROLE_OAUTH2_HASHTAG:BLOCK')] public function unblock( diff --git a/src/Controller/Api/Tag/TagSubscriptionApiController.php b/src/Controller/Api/Tag/TagSubscriptionApiController.php new file mode 100644 index 0000000000..d1d5558e76 --- /dev/null +++ b/src/Controller/Api/Tag/TagSubscriptionApiController.php @@ -0,0 +1,217 @@ + 'tag'])] + Hashtag $tag, + TagManager $manager, + RateLimiterFactoryInterface $apiUpdateLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiUpdateLimiter); + + $manager->subscribe($this->getUserOrThrow(), $tag); + + return new JsonResponse( + $this->serializeHashtag($tag), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Hashtag unsubscribed', + content: new Model(type: DomainDto::class), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'Hashtag not found', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Parameter( + name: 'name', + in: 'path', + description: 'The hashtag to unsubscribe', + schema: new OA\Schema(type: 'string'), + )] + #[OA\Tag(name: 'tag')] + #[Security(name: 'oauth2', scopes: ['hashtag:subscribe'])] + #[IsGranted('ROLE_OAUTH2_HASHTAG:SUBSCRIBE')] + public function unsubscribe( + #[MapEntity(mapping: ['name' => 'tag'])] + Hashtag $tag, + TagManager $manager, + RateLimiterFactoryInterface $apiUpdateLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiUpdateLimiter); + + $manager->unsubscribe($this->getUserOrThrow(), $tag); + + return new JsonResponse( + $this->serializeHashtag($tag), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Returns a paginated list of subscribed hashtags', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: HashtagResponseDto::class)) + ), + new OA\Property( + property: 'pagination', + ref: new Model(type: PaginationSchema::class) + ), + ] + ), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Parameter( + name: 'p', + description: 'Page of hashtags to retrieve', + in: 'query', + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + )] + #[OA\Parameter( + name: 'perPage', + description: 'Number of hashtags per page', + in: 'query', + schema: new OA\Schema(type: 'integer', default: TagRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) + )] + #[OA\Tag(name: 'tag')] + #[Security(name: 'oauth2', scopes: ['hashtag:subscribe'])] + #[IsGranted('ROLE_OAUTH2_HASHTAG:SUBSCRIBE')] + public function list( + RateLimiterFactoryInterface $apiReadLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter); + + $request = $this->request->getCurrentRequest(); + $subs = $this->repository->findSubscribedTags( + $this->getPageNb($request), + $this->getUserOrThrow(), + self::constrainPerPage($request->get('perPage', TagRepository::PER_PAGE)) + ); + + $dtos = []; + foreach ($subs->getCurrentPageResults() as $value) { + \assert($value instanceof HashtagSubscription); + $dtos[] = $this->serializeHashtag($value->hashtag); + } + + return new JsonResponse( + $this->serializePaginated($dtos, $subs), + headers: $headers + ); + } +} diff --git a/src/DTO/HashtagResponseDto.php b/src/DTO/HashtagResponseDto.php index 0ba3d941bf..9b94e9ebbd 100644 --- a/src/DTO/HashtagResponseDto.php +++ b/src/DTO/HashtagResponseDto.php @@ -13,6 +13,7 @@ class HashtagResponseDto implements \JsonSerializable public int $postCount; public int $postCommentCount; public ?bool $isBlockedByUser = null; + public ?bool $isSubscribedByUser = null; public static function create( string $tag, @@ -42,6 +43,7 @@ public function jsonSerialize(): mixed 'postCount' => $this->postCount, 'postCommentCount' => $this->postCommentCount, 'isBlockedByUser' => $this->isBlockedByUser, + 'isSubscribedByUser' => $this->isSubscribedByUser, ]; } } \ No newline at end of file diff --git a/src/DTO/OAuth2ClientDto.php b/src/DTO/OAuth2ClientDto.php index 171c03f856..7c172ee726 100644 --- a/src/DTO/OAuth2ClientDto.php +++ b/src/DTO/OAuth2ClientDto.php @@ -33,6 +33,7 @@ class OAuth2ClientDto extends ImageUploadDto implements \JsonSerializable 'domain:block', 'hashtag', 'hashtag:block', + 'hashtag:subscribe', 'entry', 'entry:create', 'entry:edit', diff --git a/src/Entity/OAuth2UserConsent.php b/src/Entity/OAuth2UserConsent.php index 36679600e0..5803dab49a 100644 --- a/src/Entity/OAuth2UserConsent.php +++ b/src/Entity/OAuth2UserConsent.php @@ -40,7 +40,7 @@ class OAuth2UserConsent 'domain:block' => 'oauth2.grant.domain.block', // Grants allowing applications to (un)subscribe or (un)block hashtags on behalf of the user 'hashtag' => 'oauth2.grant.hashtag.all', - //'hashtag:subscribe' => 'oauth2.grant.hashtag.subscribe', + 'hashtag:subscribe' => 'oauth2.grant.hashtag.subscribe', 'hashtag:block' => 'oauth2.grant.hashtag.block', // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report entries on behalf of the user 'entry' => 'oauth2.grant.entry.all', diff --git a/src/Factory/HashtagFactory.php b/src/Factory/HashtagFactory.php index c1f42e766c..a076244b96 100644 --- a/src/Factory/HashtagFactory.php +++ b/src/Factory/HashtagFactory.php @@ -38,6 +38,7 @@ public function createDto(Hashtag $tag): HashtagResponseDto if($currentUser instanceof User) { // Only return the user's settings if permission to control settings has been given $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_HASHTAG:BLOCK') ? $currentUser->isBlockedHashtag($tag) : null; + $dto->isSubscribedByUser = $this->security->isGranted('ROLE_OAUTH2_HASHTAG:SUBSCRIBE') ? $tag->isSubscribed($currentUser) : null; } return $dto; diff --git a/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php b/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php index fa98ced752..800f2584e8 100644 --- a/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php +++ b/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php @@ -54,6 +54,7 @@ public function testApiCanBlockHashtag() self::assertSame(0, $jsonData['entryCommentCount']); self::assertSame(0, $jsonData['postCount']); self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isSubscribedByUser']); self::assertTrue($jsonData['isBlockedByUser']); // Idempotent when called multiple times @@ -69,6 +70,7 @@ public function testApiCanBlockHashtag() self::assertSame(0, $jsonData['entryCommentCount']); self::assertSame(0, $jsonData['postCount']); self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isSubscribedByUser']); self::assertTrue($jsonData['isBlockedByUser']); } @@ -118,6 +120,7 @@ public function testApiCanUnblockHashtag() self::assertSame(0, $jsonData['entryCommentCount']); self::assertSame(0, $jsonData['postCount']); self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isSubscribedByUser']); self::assertFalse($jsonData['isBlockedByUser']); // Idempotent when called multiple times @@ -133,6 +136,7 @@ public function testApiCanUnblockHashtag() self::assertSame(0, $jsonData['entryCommentCount']); self::assertSame(0, $jsonData['postCount']); self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isSubscribedByUser']); self::assertFalse($jsonData['isBlockedByUser']); } @@ -195,6 +199,7 @@ public function testApiCanRetrieveBlockedHashtags() self::assertSame(0, $block['entryCommentCount']); self::assertSame(0, $block['postCount']); self::assertSame(0, $block['postCommentCount']); + self::assertNull($block['isSubscribedByUser']); self::assertTrue($block['isBlockedByUser']); $tag1Found = ($tag1Found or $block['tag'] === 'tag1'); diff --git a/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php b/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php new file mode 100644 index 0000000000..ba2e5b1599 --- /dev/null +++ b/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php @@ -0,0 +1,211 @@ +getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + $this->client->request('PUT', "/api/tag/sometag/subscribe"); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotSubscribeHashtagWithoutScope() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/sometag/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + #[Group(name: 'NonThreadSafe')] + public function testApiCanSubscribeHashtag() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/sometag/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isBlockedByUser']); + self::assertTrue($jsonData['isSubscribedByUser']); + + // Idempotent when called multiple times + $this->client->request('PUT', "/api/tag/sometag/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isBlockedByUser']); + self::assertTrue($jsonData['isSubscribedByUser']); + } + + public function testApiCannotUnsubscribeHashtagAnonymous() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + $this->client->request('PUT', "/api/tag/sometag/unsubscribe"); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotUnsubscribeHashtagWithoutScope() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/sometag/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + #[Group(name: 'NonThreadSafe')] + public function testApiCanUnsubscribeHashtag() + { + $user = $this->getUserByUsername('JohnDoe'); + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + $this->tagManager->subscribe($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/sometag/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isBlockedByUser']); + self::assertFalse($jsonData['isSubscribedByUser']); + + // Idempotent when called multiple times + $this->client->request('PUT', "/api/tag/sometag/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isBlockedByUser']); + self::assertFalse($jsonData['isSubscribedByUser']); + } + + public function testApiCannotRetrieveSubscribedHashtagsAnonymous() + { + $this->client->request('GET', '/api/tags/subscribed'); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotRetrieveSubscribedHashtagWithoutScope() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + $user = $this->getUserByUsername('JohnDoe'); + $this->tagManager->subscribe($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('GET', '/api/tags/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + public function testApiCanRetrieveSubscribedHashtags() + { + $this->getEntryByTitle('testApiCanRetrieveSubscribedHashtags', body: 'some text with #tag1 #tag2 #tag3'); + $user = $this->getUserByUsername('JohnDoe'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', "/api/tag/tag1/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + $this->client->request('PUT', "/api/tag/tag2/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->client->request('GET', '/api/tags/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); + + self::assertIsArray($jsonData['pagination']); + self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); + + $subscribed = $jsonData['items']; + self::assertIsArray($subscribed); + self::assertCount(2, $subscribed); + + $tag1Found = false; + $tag2Found = false; + foreach ($subscribed as $sub) { + self::assertIsArray($sub); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $sub); + self::assertSame(1, $sub['entryCount']); + self::assertSame(0, $sub['entryCommentCount']); + self::assertSame(0, $sub['postCount']); + self::assertSame(0, $sub['postCommentCount']); + self::assertNull($sub['isBlockedByUser']); + self::assertTrue($sub['isSubscribedByUser']); + + $tag1Found = ($tag1Found or $sub['tag'] === 'tag1'); + $tag2Found = ($tag2Found or $sub['tag'] === 'tag2'); + } + self::assertTrue($tag1Found); + self::assertTrue($tag2Found); + } +} diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 94007e3d95..00ecf1eaed 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -105,7 +105,7 @@ abstract class WebTestCase extends BaseWebTestCase protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'banner', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus', 'discoverable', 'indexable']; protected const MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'banner', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId', 'discoverable', 'indexable']; protected const DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser']; - protected const array HASHTAG_RESPONSE_KEYS = [ 'tag', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isBlockedByUser' ]; + protected const array HASHTAG_RESPONSE_KEYS = [ 'tag', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isBlockedByUser', 'isSubscribedByUser' ]; protected const KIBBY_PNG_URL_RESULT = 'a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png'; From 6353d527b5dc41ef5af2cfa06dce92e190073d05 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sun, 26 Jul 2026 20:43:07 +0000 Subject: [PATCH 08/16] linter --- src/Controller/Api/Tag/TagBaseApi.php | 3 ++- .../Api/Tag/TagBlockApiController.php | 6 ----- .../Api/Tag/TagSubscriptionApiController.php | 7 ----- src/Controller/Tag/TagBlockController.php | 2 -- .../Tag/TagSubscriptionController.php | 2 -- src/DTO/HashtagResponseDto.php | 6 ++--- src/Entity/HashtagBlock.php | 3 ++- src/Entity/HashtagSubscription.php | 3 ++- src/Event/HashtagBlockChangedEvent.php | 1 - src/Event/HashtagSubscriptionChangedEvent.php | 1 - .../Hashtag/HashtagBlockSubscriber.php | 4 +-- .../Hashtag/HashtagFollowSubscriber.php | 1 - src/Factory/HashtagFactory.php | 6 +---- src/Repository/ContentRepository.php | 26 +++++++++---------- src/Service/TagManager.php | 8 +++--- src/Twig/Components/HashtagSubComponent.php | 5 ++-- tests/FactoryTrait.php | 8 +++--- .../Controller/Api/Tag/TagBlockApiTest.php | 26 +++++++++---------- .../Api/Tag/TagSubscribeApiTest.php | 26 +++++++++---------- .../Service/Hashtag/TagBlockTest.php | 17 +++++++----- .../Service/Hashtag/TagSubscriptionTest.php | 18 +++++++------ tests/WebTestCase.php | 2 +- 22 files changed, 81 insertions(+), 100 deletions(-) diff --git a/src/Controller/Api/Tag/TagBaseApi.php b/src/Controller/Api/Tag/TagBaseApi.php index 11090bf614..c982331fd2 100644 --- a/src/Controller/Api/Tag/TagBaseApi.php +++ b/src/Controller/Api/Tag/TagBaseApi.php @@ -1,4 +1,5 @@ factory->createDto($tag); } -} \ No newline at end of file +} diff --git a/src/Controller/Api/Tag/TagBlockApiController.php b/src/Controller/Api/Tag/TagBlockApiController.php index fb44e1758e..47b9c4b838 100644 --- a/src/Controller/Api/Tag/TagBlockApiController.php +++ b/src/Controller/Api/Tag/TagBlockApiController.php @@ -4,18 +4,12 @@ namespace App\Controller\Api\Tag; -use App\Controller\Api\BaseApi; -use App\Controller\Api\Domain\DomainBaseApi; use App\DTO\DomainDto; use App\DTO\HashtagResponseDto; -use App\Entity\Domain; use App\Entity\Hashtag; use App\Entity\HashtagBlock; -use App\Factory\DomainFactory; -use App\Factory\HashtagFactory; use App\Repository\TagRepository; use App\Schema\PaginationSchema; -use App\Service\DomainManager; use App\Service\TagManager; use Nelmio\ApiDocBundle\Attribute\Model; use Nelmio\ApiDocBundle\Attribute\Security; diff --git a/src/Controller/Api/Tag/TagSubscriptionApiController.php b/src/Controller/Api/Tag/TagSubscriptionApiController.php index d1d5558e76..6e0bca08b1 100644 --- a/src/Controller/Api/Tag/TagSubscriptionApiController.php +++ b/src/Controller/Api/Tag/TagSubscriptionApiController.php @@ -4,19 +4,12 @@ namespace App\Controller\Api\Tag; -use App\Controller\Api\BaseApi; -use App\Controller\Api\Domain\DomainBaseApi; use App\DTO\DomainDto; use App\DTO\HashtagResponseDto; -use App\Entity\Domain; use App\Entity\Hashtag; -use App\Entity\HashtagBlock; use App\Entity\HashtagSubscription; -use App\Factory\DomainFactory; -use App\Factory\HashtagFactory; use App\Repository\TagRepository; use App\Schema\PaginationSchema; -use App\Service\DomainManager; use App\Service\TagManager; use Nelmio\ApiDocBundle\Attribute\Model; use Nelmio\ApiDocBundle\Attribute\Security; diff --git a/src/Controller/Tag/TagBlockController.php b/src/Controller/Tag/TagBlockController.php index 278572f303..d09bb54876 100644 --- a/src/Controller/Tag/TagBlockController.php +++ b/src/Controller/Tag/TagBlockController.php @@ -5,9 +5,7 @@ namespace App\Controller\Tag; use App\Controller\AbstractController; -use App\Entity\Domain; use App\Entity\Hashtag; -use App\Service\DomainManager; use App\Service\TagManager; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; diff --git a/src/Controller/Tag/TagSubscriptionController.php b/src/Controller/Tag/TagSubscriptionController.php index 4a61f9ba11..3484cc588f 100644 --- a/src/Controller/Tag/TagSubscriptionController.php +++ b/src/Controller/Tag/TagSubscriptionController.php @@ -5,9 +5,7 @@ namespace App\Controller\Tag; use App\Controller\AbstractController; -use App\Entity\Domain; use App\Entity\Hashtag; -use App\Service\DomainManager; use App\Service\TagManager; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Component\HttpFoundation\JsonResponse; diff --git a/src/DTO/HashtagResponseDto.php b/src/DTO/HashtagResponseDto.php index 9b94e9ebbd..3307a28487 100644 --- a/src/DTO/HashtagResponseDto.php +++ b/src/DTO/HashtagResponseDto.php @@ -1,4 +1,5 @@ tag = $tag; $toReturn->entryCount = $entryCount; @@ -46,4 +46,4 @@ public function jsonSerialize(): mixed 'isSubscribedByUser' => $this->isSubscribedByUser, ]; } -} \ No newline at end of file +} diff --git a/src/Entity/HashtagBlock.php b/src/Entity/HashtagBlock.php index 9f4feb98ca..7594b585e2 100644 --- a/src/Entity/HashtagBlock.php +++ b/src/Entity/HashtagBlock.php @@ -1,4 +1,5 @@ id; } -} \ No newline at end of file +} diff --git a/src/Entity/HashtagSubscription.php b/src/Entity/HashtagSubscription.php index aeb79d8aae..189dd7c6d2 100644 --- a/src/Entity/HashtagSubscription.php +++ b/src/Entity/HashtagSubscription.php @@ -1,4 +1,5 @@ id; } -} \ No newline at end of file +} diff --git a/src/Event/HashtagBlockChangedEvent.php b/src/Event/HashtagBlockChangedEvent.php index d571993d9a..9562c3225e 100644 --- a/src/Event/HashtagBlockChangedEvent.php +++ b/src/Event/HashtagBlockChangedEvent.php @@ -4,7 +4,6 @@ namespace App\Event; -use App\Entity\Domain; use App\Entity\Hashtag; use App\Entity\User; diff --git a/src/Event/HashtagSubscriptionChangedEvent.php b/src/Event/HashtagSubscriptionChangedEvent.php index 4c8cf4c95a..12e4f5e00d 100644 --- a/src/Event/HashtagSubscriptionChangedEvent.php +++ b/src/Event/HashtagSubscriptionChangedEvent.php @@ -4,7 +4,6 @@ namespace App\Event; -use App\Entity\Domain; use App\Entity\Hashtag; use App\Entity\User; diff --git a/src/EventSubscriber/Hashtag/HashtagBlockSubscriber.php b/src/EventSubscriber/Hashtag/HashtagBlockSubscriber.php index 1412fb8d0b..03de6a52bf 100644 --- a/src/EventSubscriber/Hashtag/HashtagBlockSubscriber.php +++ b/src/EventSubscriber/Hashtag/HashtagBlockSubscriber.php @@ -1,8 +1,8 @@ sqlHelpers->clearCachedUserHashtagBlocks($event->user); } -} \ No newline at end of file +} diff --git a/src/EventSubscriber/Hashtag/HashtagFollowSubscriber.php b/src/EventSubscriber/Hashtag/HashtagFollowSubscriber.php index 291a7084ec..83f63d141e 100644 --- a/src/EventSubscriber/Hashtag/HashtagFollowSubscriber.php +++ b/src/EventSubscriber/Hashtag/HashtagFollowSubscriber.php @@ -4,7 +4,6 @@ namespace App\EventSubscriber\Hashtag; -use App\Event\DomainSubscribedEvent; use App\Event\HashtagSubscriptionChangedEvent; use App\Utils\SqlHelpers; use Symfony\Component\EventDispatcher\EventSubscriberInterface; diff --git a/src/Factory/HashtagFactory.php b/src/Factory/HashtagFactory.php index a076244b96..b7ef5a2514 100644 --- a/src/Factory/HashtagFactory.php +++ b/src/Factory/HashtagFactory.php @@ -4,14 +4,10 @@ namespace App\Factory; -use App\DTO\DomainDto; use App\DTO\HashtagResponseDto; -use App\Entity\Domain; use App\Entity\Hashtag; use App\Entity\User; use App\Repository\TagRepository; -use App\Service\DomainManager; -use App\Service\TagManager; use Symfony\Bundle\SecurityBundle\Security; class HashtagFactory @@ -35,7 +31,7 @@ public function createDto(Hashtag $tag): HashtagResponseDto /** @var User $currentUser */ $currentUser = $this->security->getUser(); - if($currentUser instanceof User) { + if ($currentUser instanceof User) { // Only return the user's settings if permission to control settings has been given $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_HASHTAG:BLOCK') ? $currentUser->isBlockedHashtag($tag) : null; $dto->isSubscribedByUser = $this->security->isGranted('ROLE_OAUTH2_HASHTAG:SUBSCRIBE') ? $tag->isSubscribed($currentUser) : null; diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index 40ae2ca64d..8c045ac40a 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -198,8 +198,8 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use if ($user && $criteria->subscribed) { $clauseFragmentHashtag = ''; // only include the subclause if there are (/ might be) subscriptions - if($criteria->cachedUserSubscribedHashtags === null || !empty($criteria->cachedUserSubscribedHashtags)) { - if($criteria->cachedUserSubscribedHashtags === null) { + if (null === $criteria->cachedUserSubscribedHashtags || !empty($criteria->cachedUserSubscribedHashtags)) { + if (null === $criteria->cachedUserSubscribedHashtags) { $clauseFragmentHashtag = ' OR EXISTS (SELECT 1 FROM hashtag_subscription hs INNER JOIN hashtag_link hl ON hs.hashtag_id = hl.hashtag_id WHERE hs.user_id = :loggedInUser AND hl.%hl_type%_id = c.id)'; } else { $clauseFragmentHashtag = ' OR EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.%hl_type%_id = c.id AND hl.hashtag_id IN (:cachedUserSubscribedHashtags))'; @@ -224,7 +224,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use $subClauseEntry = str_replace('%hl_type%', 'entry', $subClauseEntry); if ($criteria->includeBoosts) { - //TODO should comments with a subscribed hashtag be included too? + // TODO should comments with a subscribed hashtag be included too? $repliesCommonWhere = 'c.user_id = :loggedInUser' .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' : @@ -320,17 +320,17 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use $blockingClausePostComment = $blockingClausePost; // only include the subcluase if there are (/ might be) blocks - if(null == $criteria->cachedUserBlockedHashtags || !empty($criteria->cachedUserBlockedHashtags)) { - if (null == $criteria->cachedUserBlockedHashtags) { - $blockingClauseEntry = $blockingClauseEntry . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_id = c.id AND hb.user_id = :loggedInUser)'; - $blockingClausePost = $blockingClausePost . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_id = c.id AND hb.user_id = :loggedInUser)'; - $blockingClauseEntryComment = $blockingClauseEntryComment . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_comment_id = c.id AND hb.user_id = :loggedInUser)'; - $blockingClausePostComment = $blockingClausePostComment . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_comment_id = c.id AND hb.user_id = :loggedInUser)'; + if (null === $criteria->cachedUserBlockedHashtags || !empty($criteria->cachedUserBlockedHashtags)) { + if (null === $criteria->cachedUserBlockedHashtags) { + $blockingClauseEntry = $blockingClauseEntry.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClausePost = $blockingClausePost.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClauseEntryComment = $blockingClauseEntryComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_comment_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClausePostComment = $blockingClausePostComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_comment_id = c.id AND hb.user_id = :loggedInUser)'; } else { - $blockingClauseEntry = $blockingClauseEntry . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; - $blockingClausePost = $blockingClausePost . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; - $blockingClauseEntryComment = $blockingClauseEntryComment . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; - $blockingClausePostComment = $blockingClausePostComment . ' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClauseEntry = $blockingClauseEntry.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClausePost = $blockingClausePost.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClauseEntryComment = $blockingClauseEntryComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClausePostComment = $blockingClausePostComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; $parameters['cachedUserBlockedHashtags'] = $criteria->cachedUserBlockedHashtags; } diff --git a/src/Service/TagManager.php b/src/Service/TagManager.php index 6bb569c1a1..426ada4bc9 100644 --- a/src/Service/TagManager.php +++ b/src/Service/TagManager.php @@ -6,14 +6,12 @@ use App\DTO\EntryCommentDto; use App\DTO\EntryDto; -use App\Entity\Domain; use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\Hashtag; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; -use App\Event\DomainSubscribedEvent; use App\Event\HashtagBlockChangedEvent; use App\Event\HashtagSubscriptionChangedEvent; use App\Repository\TagLinkRepository; @@ -205,14 +203,16 @@ public function unsubscribe(User $user, Hashtag $tag): void $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($tag, $user, false)); } - public function block(User $user, Hashtag $hashtag): void { + public function block(User $user, Hashtag $hashtag): void + { $user->blockHashtag($hashtag); $this->entityManager->flush(); $this->dispatcher->dispatch(new HashtagBlockChangedEvent($hashtag, $user, true)); } - public function unblock(User $user, Hashtag $hashtag): void { + public function unblock(User $user, Hashtag $hashtag): void + { $user->unblockHashtag($hashtag); $this->entityManager->flush(); diff --git a/src/Twig/Components/HashtagSubComponent.php b/src/Twig/Components/HashtagSubComponent.php index 4e0a19d61e..82bd58b9da 100644 --- a/src/Twig/Components/HashtagSubComponent.php +++ b/src/Twig/Components/HashtagSubComponent.php @@ -4,10 +4,8 @@ namespace App\Twig\Components; -use App\Entity\Domain; use App\Entity\Hashtag; use App\Entity\User; -use App\Service\TagManager; use Symfony\Bundle\SecurityBundle\Security; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\PostMount; @@ -23,7 +21,8 @@ final class HashtagSubComponent public function __construct( private readonly Security $security, - ) {} + ) { + } #[PostMount] public function postMount(): void diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php index 2c46e2203f..b9ff997414 100644 --- a/tests/FactoryTrait.php +++ b/tests/FactoryTrait.php @@ -503,7 +503,8 @@ public function createImage(string $fileName): Image return $image; } - public function createHashtag(string $name): Hashtag { + public function createHashtag(string $name): Hashtag + { $tag = new Hashtag(); $tag->tag = $name; @@ -515,10 +516,11 @@ public function createHashtag(string $name): Hashtag { return $tag; } - public function getHashtag(string $name): Hashtag { + public function getHashtag(string $name): Hashtag + { $tag = $this->hashtags->filter(fn (Hashtag $tag) => $tag->tag === $name)->first(); - if(!$tag) { + if (!$tag) { $tag = $this->createHashtag($name); } diff --git a/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php b/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php index 800f2584e8..400cc3c58c 100644 --- a/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php +++ b/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php @@ -4,18 +4,16 @@ namespace App\Tests\Functional\Controller\Api\Tag; -use App\Tests\Functional\Controller\Api\Domain\DomainRetrieveApiTest; use App\Tests\WebTestCase; use PHPUnit\Framework\Attributes\Group; class TagBlockApiTest extends WebTestCase { - public function testApiCannotBlockHashtagAnonymous() { $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); - $this->client->request('PUT', "/api/tag/sometag/block"); + $this->client->request('PUT', '/api/tag/sometag/block'); self::assertResponseStatusCodeSame(401); } @@ -28,7 +26,7 @@ public function testApiCannotBlockHashtagWithoutScope() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/sometag/block", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/block', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } @@ -42,7 +40,7 @@ public function testApiCanBlockHashtag() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/sometag/block", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/block', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); @@ -58,7 +56,7 @@ public function testApiCanBlockHashtag() self::assertTrue($jsonData['isBlockedByUser']); // Idempotent when called multiple times - $this->client->request('PUT', "/api/tag/sometag/block", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/block', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); @@ -78,7 +76,7 @@ public function testApiCannotUnblockHashtagAnonymous() { $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); - $this->client->request('PUT', "/api/tag/sometag/unblock"); + $this->client->request('PUT', '/api/tag/sometag/unblock'); self::assertResponseStatusCodeSame(401); } @@ -91,7 +89,7 @@ public function testApiCannotUnblockHashtagWithoutScope() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/sometag/unblock", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/unblock', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } @@ -108,7 +106,7 @@ public function testApiCanUnblockHashtag() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/sometag/unblock", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/unblock', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); @@ -124,7 +122,7 @@ public function testApiCanUnblockHashtag() self::assertFalse($jsonData['isBlockedByUser']); // Idempotent when called multiple times - $this->client->request('PUT', "/api/tag/sometag/unblock", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/unblock', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); @@ -171,9 +169,9 @@ public function testApiCanRetrieveBlockedHashtags() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/tag1/block", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/tag1/block', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); - $this->client->request('PUT', "/api/tag/tag2/block", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/tag2/block', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $this->client->request('GET', '/api/tags/blocked', server: ['HTTP_AUTHORIZATION' => $token]); @@ -202,8 +200,8 @@ public function testApiCanRetrieveBlockedHashtags() self::assertNull($block['isSubscribedByUser']); self::assertTrue($block['isBlockedByUser']); - $tag1Found = ($tag1Found or $block['tag'] === 'tag1'); - $tag2Found = ($tag2Found or $block['tag'] === 'tag2'); + $tag1Found = ($tag1Found or 'tag1' === $block['tag']); + $tag2Found = ($tag2Found or 'tag2' === $block['tag']); } self::assertTrue($tag1Found); self::assertTrue($tag2Found); diff --git a/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php b/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php index ba2e5b1599..a4e502eaae 100644 --- a/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php +++ b/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php @@ -4,18 +4,16 @@ namespace App\Tests\Functional\Controller\Api\Tag; -use App\Tests\Functional\Controller\Api\Domain\DomainRetrieveApiTest; use App\Tests\WebTestCase; use PHPUnit\Framework\Attributes\Group; class TagSubscribeApiTest extends WebTestCase { - public function testApiCannotSubscribeHashtagAnonymous() { $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); - $this->client->request('PUT', "/api/tag/sometag/subscribe"); + $this->client->request('PUT', '/api/tag/sometag/subscribe'); self::assertResponseStatusCodeSame(401); } @@ -28,7 +26,7 @@ public function testApiCannotSubscribeHashtagWithoutScope() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/sometag/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } @@ -42,7 +40,7 @@ public function testApiCanSubscribeHashtag() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/sometag/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); @@ -58,7 +56,7 @@ public function testApiCanSubscribeHashtag() self::assertTrue($jsonData['isSubscribedByUser']); // Idempotent when called multiple times - $this->client->request('PUT', "/api/tag/sometag/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); @@ -78,7 +76,7 @@ public function testApiCannotUnsubscribeHashtagAnonymous() { $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); - $this->client->request('PUT', "/api/tag/sometag/unsubscribe"); + $this->client->request('PUT', '/api/tag/sometag/unsubscribe'); self::assertResponseStatusCodeSame(401); } @@ -91,7 +89,7 @@ public function testApiCannotUnsubscribeHashtagWithoutScope() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/sometag/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseStatusCodeSame(403); } @@ -108,7 +106,7 @@ public function testApiCanUnsubscribeHashtag() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/sometag/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); @@ -124,7 +122,7 @@ public function testApiCanUnsubscribeHashtag() self::assertFalse($jsonData['isSubscribedByUser']); // Idempotent when called multiple times - $this->client->request('PUT', "/api/tag/sometag/unsubscribe", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/sometag/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); @@ -171,9 +169,9 @@ public function testApiCanRetrieveSubscribedHashtags() $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); $token = $codes['token_type'].' '.$codes['access_token']; - $this->client->request('PUT', "/api/tag/tag1/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/tag1/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); - $this->client->request('PUT', "/api/tag/tag2/subscribe", server: ['HTTP_AUTHORIZATION' => $token]); + $this->client->request('PUT', '/api/tag/tag2/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); self::assertResponseIsSuccessful(); $this->client->request('GET', '/api/tags/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); @@ -202,8 +200,8 @@ public function testApiCanRetrieveSubscribedHashtags() self::assertNull($sub['isBlockedByUser']); self::assertTrue($sub['isSubscribedByUser']); - $tag1Found = ($tag1Found or $sub['tag'] === 'tag1'); - $tag2Found = ($tag2Found or $sub['tag'] === 'tag2'); + $tag1Found = ($tag1Found or 'tag1' === $sub['tag']); + $tag2Found = ($tag2Found or 'tag2' === $sub['tag']); } self::assertTrue($tag1Found); self::assertTrue($tag2Found); diff --git a/tests/Functional/Service/Hashtag/TagBlockTest.php b/tests/Functional/Service/Hashtag/TagBlockTest.php index cacfce595d..f9d2a8ae68 100644 --- a/tests/Functional/Service/Hashtag/TagBlockTest.php +++ b/tests/Functional/Service/Hashtag/TagBlockTest.php @@ -1,4 +1,5 @@ getUserByUsername('John Doe'); $user2 = $this->getUserByUsername('Jane Doe'); $tagNeutral = $this->getHashtag('abc'); @@ -24,7 +25,8 @@ public function testBlock() { self::assertCount(0, $user2->blockedHashtags); } - public function testUnblock() { + public function testUnblock() + { $user1 = $this->getUserByUsername('John Doe'); $user2 = $this->getUserByUsername('Jane Doe'); $tag1 = $this->getHashtag('abc'); @@ -42,7 +44,8 @@ public function testUnblock() { self::assertSame($tag1->tag, $user2->blockedHashtags->first()->hashtag->tag); } - public function testBlockedHashtagIsHiddenInCombinedWithCache() { + public function testBlockedHashtagIsHiddenInCombinedWithCache() + { $user = $this->getUserByUsername('John Doe'); $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('notWanted'); @@ -81,7 +84,8 @@ public function testBlockedHashtagIsHiddenInCombinedWithCache() { self::assertCount(4, $result); } - public function testBlockedHashtagIsHiddenInCombinedWithoutCache() { + public function testBlockedHashtagIsHiddenInCombinedWithoutCache() + { $user = $this->getUserByUsername('John Doe'); $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('notWanted'); @@ -166,5 +170,4 @@ public function testBlockedHashtagIsHiddenInPostComments() self::assertSame($commentShowing->getId(), $result[0]->getId()); self::assertCount(1, $result); } - -} \ No newline at end of file +} diff --git a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php index 488b7df2ff..bf4b6284df 100644 --- a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php +++ b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php @@ -1,17 +1,16 @@ getUserByUsername('John Doe'); $user2 = $this->getUserByUsername('Jane Doe'); $tagNeutral = $this->getHashtag('abc'); @@ -24,7 +23,8 @@ public function testSubscribe() { self::assertCount(0, $user2->subscribedHashtags); } - public function testUnsubscribe() { + public function testUnsubscribe() + { $user1 = $this->getUserByUsername('John Doe'); $user2 = $this->getUserByUsername('Jane Doe'); $tag1 = $this->getHashtag('abc'); @@ -42,7 +42,8 @@ public function testUnsubscribe() { self::assertSame($tag1->tag, $user2->subscribedHashtags->first()->hashtag->tag); } - public function testSubscribedHashtagIsIncludedInCombinedWithCache() { + public function testSubscribedHashtagIsIncludedInCombinedWithCache() + { $user = $this->getUserByUsername('John Doe'); $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('interesting'); @@ -78,7 +79,8 @@ public function testSubscribedHashtagIsIncludedInCombinedWithCache() { self::assertCount(2, $result); } - public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() { + public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() + { $user = $this->getUserByUsername('John Doe'); $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('interesting'); @@ -112,4 +114,4 @@ public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() { self::assertSame($postShowing->getId(), $result[1]->getId()); self::assertCount(2, $result); } -} \ No newline at end of file +} diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 00ecf1eaed..6871dc2594 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -105,7 +105,7 @@ abstract class WebTestCase extends BaseWebTestCase protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'banner', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus', 'discoverable', 'indexable']; protected const MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'banner', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId', 'discoverable', 'indexable']; protected const DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser']; - protected const array HASHTAG_RESPONSE_KEYS = [ 'tag', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isBlockedByUser', 'isSubscribedByUser' ]; + protected const array HASHTAG_RESPONSE_KEYS = ['tag', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isBlockedByUser', 'isSubscribedByUser']; protected const KIBBY_PNG_URL_RESULT = 'a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png'; From 43e1e97808e00b02c5cf21c5d37e199e8ec1c63e Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sun, 26 Jul 2026 21:14:14 +0000 Subject: [PATCH 09/16] fix tests --- .../Service/Hashtag/TagBlockTest.php | 20 +++++++++---------- .../Service/Hashtag/TagSubscriptionTest.php | 16 +++++++-------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/Functional/Service/Hashtag/TagBlockTest.php b/tests/Functional/Service/Hashtag/TagBlockTest.php index f9d2a8ae68..878dfaba1f 100644 --- a/tests/Functional/Service/Hashtag/TagBlockTest.php +++ b/tests/Functional/Service/Hashtag/TagBlockTest.php @@ -50,16 +50,16 @@ public function testBlockedHashtagIsHiddenInCombinedWithCache() $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('notWanted'); - $magazine = $this->getMagazineByName('HashtagBlockTest'); + $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInCombinedWithCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #wanted'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notWanted'); - usleep(10000); + usleep(20000); $entryCommentShowing = $this->createEntryComment('some text #wanted', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notWanted', $entryShowing, $contentCreator); - usleep(10000); + usleep(20000); $postShowing = $this->createPost('some text #wanted', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notWanted', $magazine, $contentCreator); - usleep(10000); + usleep(20000); $postCommentShowing = $this->createPostComment('some text #wanted', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notWanted', $postShowing, $contentCreator); @@ -90,16 +90,16 @@ public function testBlockedHashtagIsHiddenInCombinedWithoutCache() $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('notWanted'); - $magazine = $this->getMagazineByName('HashtagBlockTest'); + $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInCombinedWithoutCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #wanted'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notWanted'); - usleep(10000); + usleep(20000); $entryCommentShowing = $this->createEntryComment('some text #wanted', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notWanted', $entryShowing, $contentCreator); - usleep(10000); + usleep(20000); $postShowing = $this->createPost('some text #wanted', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notWanted', $magazine, $contentCreator); - usleep(10000); + usleep(20000); $postCommentShowing = $this->createPostComment('some text #wanted', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notWanted', $postShowing, $contentCreator); @@ -129,7 +129,7 @@ public function testBlockedHashtagIsHiddenInEntryComments() $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('notWanted'); - $magazine = $this->getMagazineByName('HashtagBlockTest'); + $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInEntryComments'); $entry = $this->createEntry('something', $magazine, $contentCreator, body: 'some text'); $commentShowing = $this->createEntryComment('some text #wanted', $entry, $contentCreator); $commentHidden = $this->createEntryComment('some text #notWanted', $entry, $contentCreator); @@ -153,7 +153,7 @@ public function testBlockedHashtagIsHiddenInPostComments() $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('notWanted'); - $magazine = $this->getMagazineByName('HashtagBlockTest'); + $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInPostComments'); $post = $this->createPost('something', $magazine, $contentCreator); $commentShowing = $this->createPostComment('some text #wanted', $post, $contentCreator); $commentHidden = $this->createPostComment('some text #notWanted', $post, $contentCreator); diff --git a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php index bf4b6284df..5d6b57b617 100644 --- a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php +++ b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php @@ -48,16 +48,16 @@ public function testSubscribedHashtagIsIncludedInCombinedWithCache() $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('interesting'); - $magazine = $this->getMagazineByName('TagSubscriptionTest'); + $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedWithCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); - usleep(10000); + usleep(20000); $entryCommentShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); - usleep(10000); + usleep(20000); $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); - usleep(10000); + usleep(20000); $postCommentShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); @@ -85,16 +85,16 @@ public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('interesting'); - $magazine = $this->getMagazineByName('TagSubscriptionTest'); + $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedWithoutCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); - usleep(10000); + usleep(20000); $entryCommentShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); - usleep(10000); + usleep(20000); $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); - usleep(10000); + usleep(20000); $postCommentShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); From e6edebd8fc03d526df6e85e6340583da48911cf3 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Mon, 27 Jul 2026 23:04:29 +0000 Subject: [PATCH 10/16] fix tests --- .../Misc/Entry/CrosspostDetectionTest.php | 9 +++-- .../Service/Hashtag/TagBlockTest.php | 40 ++++++++++++++----- .../Service/Hashtag/TagSubscriptionTest.php | 30 ++++++++++---- tests/WebTestCase.php | 12 ++++++ 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/tests/Functional/Misc/Entry/CrosspostDetectionTest.php b/tests/Functional/Misc/Entry/CrosspostDetectionTest.php index 6a77be6fc6..091781f6a8 100644 --- a/tests/Functional/Misc/Entry/CrosspostDetectionTest.php +++ b/tests/Functional/Misc/Entry/CrosspostDetectionTest.php @@ -59,16 +59,17 @@ public function testCrosspostsByUrl(): void { $user = $this->getUserByUsername('JohnDoe'); $magazine1 = $this->getMagazineByName('acme1'); - $entry1 = $this->createEntry('article 001', $magazine1, $user, url: 'https://duckduckgo.com'); + // make the URL invalid as else it would sometimes pull an image and sometimes it would not + $entry1 = $this->createEntry(' article 001', $magazine1, $user, url: 'https://duckduckgo-fake.com'); sleep(1); $magazine2 = $this->getMagazineByName('acme2'); - $entry2 = $this->createEntry('article 001', $magazine2, $user, url: 'https://duckduckgo.com'); + $entry2 = $this->createEntry('article 001', $magazine2, $user, url: 'https://duckduckgo-fake.com'); sleep(1); $magazine3 = $this->getMagazineByName('acme3'); - $entry3 = $this->createEntry('article with url', $magazine3, $user, url: 'https://duckduckgo.com'); + $entry3 = $this->createEntry('article with url', $magazine3, $user, url: 'https://duckduckgo-fake.com'); sleep(1); $magazine4 = $this->getMagazineByName('acme4'); - $entry4 = $this->createEntry('article 001', $magazine4, $user, url: 'https://google.com'); + $entry4 = $this->createEntry('article 001', $magazine4, $user, url: 'https://google-fake.com'); $this->entityManager->persist($entry1); $this->entityManager->persist($entry2); $this->entityManager->persist($entry3); diff --git a/tests/Functional/Service/Hashtag/TagBlockTest.php b/tests/Functional/Service/Hashtag/TagBlockTest.php index 878dfaba1f..dc0edc4d0d 100644 --- a/tests/Functional/Service/Hashtag/TagBlockTest.php +++ b/tests/Functional/Service/Hashtag/TagBlockTest.php @@ -3,6 +3,10 @@ namespace App\Tests\Functional\Service\Hashtag; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\Post; +use App\Entity\PostComment; use App\PageView\EntryCommentPageView; use App\PageView\EntryPageView; use App\PageView\PostCommentPageView; @@ -53,22 +57,26 @@ public function testBlockedHashtagIsHiddenInCombinedWithCache() $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInCombinedWithCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #wanted'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notWanted'); - usleep(20000); $entryCommentShowing = $this->createEntryComment('some text #wanted', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notWanted', $entryShowing, $contentCreator); - usleep(20000); $postShowing = $this->createPost('some text #wanted', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notWanted', $magazine, $contentCreator); - usleep(20000); $postCommentShowing = $this->createPostComment('some text #wanted', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notWanted', $postShowing, $contentCreator); + $this->setContentTime($entryHidden, $entryShowing, 2); + $this->setContentTime($entryCommentShowing, $entryShowing, 4); + $this->setContentTime($entryCommentHidden, $entryShowing, 6); + $this->setContentTime($postShowing, $entryShowing, 8); + $this->setContentTime($postHidden, $entryShowing, 10); + $this->setContentTime($postCommentShowing, $entryShowing, 12); + $this->setContentTime($postCommentHidden, $entryShowing, 14); $user->follow($contentCreator); $this->tagManager->block($user, $tag); $criteria = new EntryPageView(1, $this->security) ->setContent(Criteria::CONTENT_COMBINED) - ->showSortOption(Criteria::SORT_NEW); + ->showSortOption(Criteria::SORT_OLD); $criteria->magazine = $magazine; $criteria->includeBoosts = true; $criteria->perPage = 5; @@ -77,9 +85,13 @@ public function testBlockedHashtagIsHiddenInCombinedWithCache() $fanta = $this->contentRepository->findByCriteria($criteria, $user); $result = $fanta->getCurrentPageResults(); + self::assertInstanceOf(Entry::class, $result[0]); self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(EntryComment::class, $result[1]); self::assertSame($entryCommentShowing->getId(), $result[1]->getId()); + self::assertInstanceOf(Post::class, $result[2]); self::assertSame($postShowing->getId(), $result[2]->getId()); + self::assertInstanceOf(PostComment::class, $result[3]); self::assertSame($postCommentShowing->getId(), $result[3]->getId()); self::assertCount(4, $result); } @@ -93,22 +105,26 @@ public function testBlockedHashtagIsHiddenInCombinedWithoutCache() $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInCombinedWithoutCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #wanted'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notWanted'); - usleep(20000); $entryCommentShowing = $this->createEntryComment('some text #wanted', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notWanted', $entryShowing, $contentCreator); - usleep(20000); $postShowing = $this->createPost('some text #wanted', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notWanted', $magazine, $contentCreator); - usleep(20000); $postCommentShowing = $this->createPostComment('some text #wanted', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notWanted', $postShowing, $contentCreator); + $this->setContentTime($entryHidden, $entryShowing, 2); + $this->setContentTime($entryCommentShowing, $entryShowing, 4); + $this->setContentTime($entryCommentHidden, $entryShowing, 6); + $this->setContentTime($postShowing, $entryShowing, 8); + $this->setContentTime($postHidden, $entryShowing, 10); + $this->setContentTime($postCommentShowing, $entryShowing, 12); + $this->setContentTime($postCommentHidden, $entryShowing, 14); $user->follow($contentCreator); $this->tagManager->block($user, $tag); $criteria = new EntryPageView(1, $this->security) ->setContent(Criteria::CONTENT_COMBINED) - ->showSortOption(Criteria::SORT_NEW); + ->showSortOption(Criteria::SORT_OLD); $criteria->magazine = $magazine; $criteria->includeBoosts = true; $criteria->perPage = 5; @@ -116,9 +132,13 @@ public function testBlockedHashtagIsHiddenInCombinedWithoutCache() $fanta = $this->contentRepository->findByCriteria($criteria, $user); $result = $fanta->getCurrentPageResults(); + self::assertInstanceOf(Entry::class, $result[0]); self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(EntryComment::class, $result[1]); self::assertSame($entryCommentShowing->getId(), $result[1]->getId()); + self::assertInstanceOf(Post::class, $result[2]); self::assertSame($postShowing->getId(), $result[2]->getId()); + self::assertInstanceOf(PostComment::class, $result[3]); self::assertSame($postCommentShowing->getId(), $result[3]->getId()); self::assertCount(4, $result); } @@ -137,7 +157,7 @@ public function testBlockedHashtagIsHiddenInEntryComments() $this->tagManager->block($user, $tag); $criteria = new EntryCommentPageView(1, $this->security); - $criteria->showSortOption(Criteria::SORT_NEW); + $criteria->showSortOption(Criteria::SORT_OLD); $criteria->entry = $entry; $fanta = $this->entryCommentRepository->findByCriteria($criteria, $user); @@ -161,7 +181,7 @@ public function testBlockedHashtagIsHiddenInPostComments() $this->tagManager->block($user, $tag); $criteria = new PostCommentPageView(1, $this->security); - $criteria->showSortOption(Criteria::SORT_NEW); + $criteria->showSortOption(Criteria::SORT_OLD); $criteria->post = $post; $fanta = $this->postCommentRepository->findByCriteria($criteria, $user); diff --git a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php index 5d6b57b617..e0864144e3 100644 --- a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php +++ b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php @@ -3,6 +3,8 @@ namespace App\Tests\Functional\Service\Hashtag; +use App\Entity\Entry; +use App\Entity\Post; use App\PageView\EntryPageView; use App\Repository\Criteria; use App\Tests\WebTestCase; @@ -51,21 +53,25 @@ public function testSubscribedHashtagIsIncludedInCombinedWithCache() $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedWithCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); - usleep(20000); $entryCommentShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); - usleep(20000); $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); - usleep(20000); $postCommentShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); + $this->setContentTime($entryHidden, $entryShowing, 2); + $this->setContentTime($entryCommentShowing, $entryShowing, 4); + $this->setContentTime($entryCommentHidden, $entryShowing, 6); + $this->setContentTime($postShowing, $entryShowing, 8); + $this->setContentTime($postHidden, $entryShowing, 10); + $this->setContentTime($postCommentShowing, $entryShowing, 12); + $this->setContentTime($postCommentHidden, $entryShowing, 14); $this->tagManager->subscribe($user, $tag); $criteria = new EntryPageView(1, $this->security) ->setContent(Criteria::CONTENT_COMBINED) - ->showSortOption(Criteria::SORT_NEW); + ->showSortOption(Criteria::SORT_OLD); $criteria->subscribed = true; $criteria->includeBoosts = false; $criteria->perPage = 5; @@ -74,7 +80,9 @@ public function testSubscribedHashtagIsIncludedInCombinedWithCache() $fanta = $this->contentRepository->findByCriteria($criteria, $user); $result = $fanta->getCurrentPageResults(); + self::assertInstanceOf(Entry::class, $result[0]); self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(Post::class, $result[1]); self::assertSame($postShowing->getId(), $result[1]->getId()); self::assertCount(2, $result); } @@ -88,21 +96,25 @@ public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedWithoutCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); - usleep(20000); $entryCommentShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); - usleep(20000); $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); - usleep(20000); $postCommentShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); + $this->setContentTime($entryHidden, $entryShowing, 2); + $this->setContentTime($entryCommentShowing, $entryShowing, 4); + $this->setContentTime($entryCommentHidden, $entryShowing, 6); + $this->setContentTime($postShowing, $entryShowing, 8); + $this->setContentTime($postHidden, $entryShowing, 10); + $this->setContentTime($postCommentShowing, $entryShowing, 12); + $this->setContentTime($postCommentHidden, $entryShowing, 14); $this->tagManager->subscribe($user, $tag); $criteria = new EntryPageView(1, $this->security) ->setContent(Criteria::CONTENT_COMBINED) - ->showSortOption(Criteria::SORT_NEW); + ->showSortOption(Criteria::SORT_OLD); $criteria->subscribed = true; $criteria->includeBoosts = false; $criteria->perPage = 5; @@ -110,7 +122,9 @@ public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() $fanta = $this->contentRepository->findByCriteria($criteria, $user); $result = $fanta->getCurrentPageResults(); + self::assertInstanceOf(Entry::class, $result[0]); self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(Post::class, $result[1]); self::assertSame($postShowing->getId(), $result[1]->getId()); self::assertCount(2, $result); } diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 6871dc2594..ec6adab2c4 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -4,6 +4,10 @@ namespace App\Tests; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\Post; +use App\Entity\PostComment; use App\Factory\ActivityPub\EntryPageFactory; use App\Factory\ActivityPub\GroupFactory; use App\Factory\ActivityPub\PersonFactory; @@ -339,6 +343,14 @@ public static function removeTimeElements(string $content): string return preg_replace($pattern, '', $content); } + public function setContentTime(Entry|EntryComment|Post|PostComment $subject, Entry|EntryComment|Post|PostComment $reference, int $seconds): void + { + $subject->createdAt = $reference->getCreatedAt()->add(\DateInterval::createFromDateString($seconds.' seconds')); + $subject->lastBoostedAt = $subject->createdAt; + $this->entityManager->persist($subject); + $this->entityManager->flush(); + } + protected function tearDown(): void { parent::tearDown(); From 7ef7b3c23cb9998cf7649fc3ae48c4c752e5ae2e Mon Sep 17 00:00:00 2001 From: blued_gear Date: Mon, 27 Jul 2026 23:08:38 +0000 Subject: [PATCH 11/16] fix creation of temporary images in tests --- tests/FactoryTrait.php | 31 +++++++++++++---- .../Comment/EntryCommentCreateApiTest.php | 33 ++++--------------- .../Api/Entry/EntryCreateApiNewTest.php | 30 +++++------------ .../Api/Entry/EntryCreateApiTest.php | 23 ++++--------- .../Admin/MagazineDeleteIconApiTest.php | 11 +------ .../Admin/MagazineUpdateThemeApiTest.php | 24 +++----------- .../Api/OAuth2/OAuth2ClientApiTest.php | 5 +-- .../Post/Comment/PostCommentCreateApiTest.php | 30 ++++------------- .../Controller/Api/Post/PostCreateApiTest.php | 17 +++------- .../Api/User/UserUpdateImagesApiTest.php | 31 ++++------------- .../EntryCommentCreateControllerTest.php | 6 ---- .../Entry/EntryCreateControllerTest.php | 8 ----- .../PostCommentCreateControllerTest.php | 6 ---- .../Post/PostCreateControllerTest.php | 6 ---- .../User/Profile/UserEditControllerTest.php | 8 ----- tests/WebTestCase.php | 2 ++ 16 files changed, 70 insertions(+), 201 deletions(-) diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php index b9ff997414..85ff82e88e 100644 --- a/tests/FactoryTrait.php +++ b/tests/FactoryTrait.php @@ -634,26 +634,43 @@ public function getKibbyFlippedImageDto(): ImageDto return $this->getKibbyImageVariantDto('_flipped'); } - private function getKibbyImageVariantDto(string $suffix): ImageDto + public function getKibbyImageUpload(): UploadedFile { - $imageRepository = $this->imageRepository; - $imageFactory = $this->imageFactory; + return $this->getKibbyImageVariantUpload(''); + } - if (!file_exists(\dirname($this->kibbyPath).'/copy')) { - if (!mkdir(\dirname($this->kibbyPath).'/copy')) { + public function getKibbyFlippedImageUpload(): UploadedFile + { + return $this->getKibbyImageVariantUpload('_flipped'); + } + + private function getKibbyImageVariantUpload(string $suffix): UploadedFile + { + if (!file_exists($this->imageUploadTmpDir)) { + if (!mkdir($this->imageUploadTmpDir)) { throw new \Exception('The copy dir could not be created'); } } // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = \dirname($this->kibbyPath).'/copy/'.bin2hex(random_bytes(32)).'.png'; + $tmpPath = $this->imageUploadTmpDir.bin2hex(random_bytes(32)).'.png'; $srcPath = \dirname($this->kibbyPath).'/'.basename($this->kibbyPath, '.png').$suffix.'.png'; if (!file_exists($srcPath)) { throw new \Exception('For some reason the kibby image got deleted'); } copy($srcPath, $tmpPath); + + return new UploadedFile($tmpPath, 'kibby_emoji.png', 'image/png'); + } + + private function getKibbyImageVariantDto(string $suffix): ImageDto + { + $imageRepository = $this->imageRepository; + $imageFactory = $this->imageFactory; + + $imgUpload = $this->getKibbyImageVariantUpload($suffix); /** @var Image $image */ - $image = $imageRepository->findOrCreateFromUpload(new UploadedFile($tmpPath, 'kibby_emoji.png', 'image/png')); + $image = $imageRepository->findOrCreateFromUpload($imgUpload); self::assertNotNull($image); $image->altText = 'kibby'; $this->entityManager->persist($image); diff --git a/tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php b/tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php index 8facbd3430..34380095f9 100644 --- a/tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php +++ b/tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php @@ -5,7 +5,6 @@ namespace App\Tests\Functional\Controller\Api\Entry\Comment; use App\Tests\WebTestCase; -use Symfony\Component\HttpFoundation\File\UploadedFile; class EntryCommentCreateApiTest extends WebTestCase { @@ -189,10 +188,7 @@ public function testApiCannotCreateImageCommentAnonymous(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/image", @@ -213,10 +209,7 @@ public function testApiCannotCreateImageCommentWithoutScope(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -244,10 +237,7 @@ public function testApiCanCreateImageComment(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); @@ -293,10 +283,7 @@ public function testApiCannotCreateImageCommentReplyAnonymous(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply/image", @@ -317,10 +304,7 @@ public function testApiCannotCreateImageCommentReplyWithoutScope(): void 'isAdult' => false, ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -350,11 +334,8 @@ public function testApiCanCreateImageCommentReply(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); - $resultingPath = $imageManager->getFilePath($image->getFilename()); + $image = $this->getKibbyImageUpload(); + $resultingPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); diff --git a/tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php b/tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php index c7489ac8b6..2033159a2f 100644 --- a/tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php +++ b/tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php @@ -5,7 +5,6 @@ namespace App\Tests\Functional\Controller\Api\Entry; use App\Tests\WebTestCase; -use Symfony\Component\HttpFoundation\File\UploadedFile; class EntryCreateApiNewTest extends WebTestCase { @@ -254,13 +253,10 @@ public function testApiCanCreateLinkWithImageEntry(): void $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", @@ -323,9 +319,7 @@ public function testApiCannotCreateImageEntryAnonymous(): void 'isAdult' => false, ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", @@ -346,9 +340,7 @@ public function testApiCannotCreateImageEntryWithoutScope(): void 'isAdult' => false, ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -380,13 +372,10 @@ public function testApiCanCreateImageEntry(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; @@ -456,13 +445,10 @@ public function testApiCanCreateImageWithBodyEntry(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; diff --git a/tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php b/tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php index b6a138872c..056e521739 100644 --- a/tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php +++ b/tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php @@ -5,7 +5,6 @@ namespace App\Tests\Functional\Controller\Api\Entry; use App\Tests\WebTestCase; -use Symfony\Component\HttpFoundation\File\UploadedFile; class EntryCreateApiTest extends WebTestCase { @@ -223,9 +222,7 @@ public function testApiCannotCreateImageEntryAnonymous(): void 'isAdult' => false, ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/image", @@ -246,9 +243,7 @@ public function testApiCannotCreateImageEntryWithoutScope(): void 'isAdult' => false, ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -280,13 +275,10 @@ public function testApiCanCreateImageEntry(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; @@ -356,13 +348,10 @@ public function testApiCanCreateImageEntryWithBody(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; diff --git a/tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php b/tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php index 024c9d854d..cf229c92e4 100644 --- a/tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php +++ b/tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php @@ -8,16 +8,9 @@ use App\Tests\Functional\Controller\Api\Magazine\MagazineRetrieveApiTest; use App\Tests\WebTestCase; use PHPUnit\Framework\Attributes\Group; -use Symfony\Component\HttpFoundation\File\UploadedFile; class MagazineDeleteIconApiTest extends WebTestCase { - public function setUp(): void - { - parent::setUp(); - $this->kibbyPath = \dirname(__FILE__, 6).'/assets/kibby_emoji.png'; - } - public function testApiCannotDeleteMagazineIconAnonymous(): void { $magazine = $this->getMagazineByName('test'); @@ -73,9 +66,7 @@ public function testApiCanDeleteMagazineIcon(): void $magazine = $this->getMagazineByName('test'); - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $upload = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $upload = $this->getKibbyImageUpload(); $imageRepository = $this->imageRepository; $image = $imageRepository->findOrCreateFromUpload($upload); diff --git a/tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php b/tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php index 5769339794..6f14c81742 100644 --- a/tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php +++ b/tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php @@ -7,18 +7,11 @@ use App\DTO\ModeratorDto; use App\Tests\Functional\Controller\Api\Magazine\MagazineRetrieveApiTest; use App\Tests\WebTestCase; -use Symfony\Component\HttpFoundation\File\UploadedFile; class MagazineUpdateThemeApiTest extends WebTestCase { public const MAGAZINE_THEME_RESPONSE_KEYS = ['magazine', 'customCss', 'icon', 'banner']; - public function setUp(): void - { - parent::setUp(); - $this->kibbyPath = \dirname(__FILE__, 6).'/assets/kibby_emoji.png'; - } - public function testApiCannotUpdateMagazineThemeAnonymous(): void { $magazine = $this->getMagazineByName('test'); @@ -75,10 +68,7 @@ public function testApiCanUpdateMagazineThemeWithCustomCss(): void $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.tmp'); - $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $customCss = 'a {background: red;}'; @@ -118,13 +108,10 @@ public function testApiCanUpdateMagazineThemeWithBackgroundImage(): void $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $backgroundImage = 'shape1'; @@ -163,10 +150,7 @@ public function testCanUpdateMagazineBanner(): void $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.tmp'); - $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'PUT', "/api/moderate/magazine/{$magazine->getId()}/banner", diff --git a/tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php b/tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php index 28e9c93d73..e628af31d7 100644 --- a/tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php +++ b/tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php @@ -6,7 +6,6 @@ use App\Tests\WebTestCase; use PHPUnit\Framework\Attributes\Group; -use Symfony\Component\HttpFoundation\File\UploadedFile; class OAuth2ClientApiTest extends WebTestCase { @@ -152,9 +151,7 @@ public function testApiCanCreateWorkingClientWithImage(): void ], ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request('POST', '/api/client-with-logo', $requestData, files: ['uploadImage' => $image]); diff --git a/tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php b/tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php index d92a738b0b..78c8a49fdb 100644 --- a/tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php +++ b/tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php @@ -5,7 +5,6 @@ namespace App\Tests\Functional\Controller\Api\Post\Comment; use App\Tests\WebTestCase; -use Symfony\Component\HttpFoundation\File\UploadedFile; class PostCommentCreateApiTest extends WebTestCase { @@ -189,9 +188,7 @@ public function testApiCannotCreateImageCommentAnonymous(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/image", @@ -212,10 +209,7 @@ public function testApiCannotCreateImageCommentWithoutScope(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -243,9 +237,7 @@ public function testApiCanCreateImageComment(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); @@ -291,9 +283,7 @@ public function testApiCannotCreateImageCommentReplyAnonymous(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply/image", @@ -314,10 +304,7 @@ public function testApiCannotCreateImageCommentReplyWithoutScope(): void 'isAdult' => false, ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.tmp'); - $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -346,13 +333,10 @@ public function testApiCanCreateImageCommentReply(): void 'alt' => 'It\'s Kibby!', ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); diff --git a/tests/Functional/Controller/Api/Post/PostCreateApiTest.php b/tests/Functional/Controller/Api/Post/PostCreateApiTest.php index 231a7586be..65945d3f82 100644 --- a/tests/Functional/Controller/Api/Post/PostCreateApiTest.php +++ b/tests/Functional/Controller/Api/Post/PostCreateApiTest.php @@ -5,7 +5,6 @@ namespace App\Tests\Functional\Controller\Api\Post; use App\Tests\WebTestCase; -use Symfony\Component\HttpFoundation\File\UploadedFile; class PostCreateApiTest extends WebTestCase { @@ -102,9 +101,7 @@ public function testApiCannotCreateImagePostAnonymous(): void 'isAdult' => false, ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/posts/image", @@ -122,10 +119,7 @@ public function testApiCannotCreateImagePostWithoutScope(): void 'isAdult' => false, ]; - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -154,13 +148,10 @@ public function testApiCanCreateImagePost(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create'); $token = $codes['token_type'].' '.$codes['access_token']; diff --git a/tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php b/tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php index 9f3d3d31b9..f0a5be6bc1 100644 --- a/tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php +++ b/tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php @@ -5,18 +5,9 @@ namespace App\Tests\Functional\Controller\Api\User; use App\Tests\WebTestCase; -use Symfony\Component\HttpFoundation\File\UploadedFile; class UserUpdateImagesApiTest extends WebTestCase { - public string $kibbyPath; - - public function setUp(): void - { - parent::setUp(); - $this->kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; - } - public function testApiCannotUpdateCurrentUserAvatarWithoutScope(): void { self::createOAuth2AuthCodeClient(); @@ -24,9 +15,7 @@ public function testApiCannotUpdateCurrentUserAvatarWithoutScope(): void $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', '/api/users/avatar', @@ -43,9 +32,7 @@ public function testApiCannotUpdateCurrentUserCoverWithoutScope(): void $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - copy($this->kibbyPath, $this->kibbyPath.'.tmp'); - $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $this->client->request( 'POST', '/api/users/cover', @@ -84,13 +71,10 @@ public function testApiCanUpdateAndDeleteCurrentUserAvatar(): void $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $image = $this->getKibbyImageUpload(); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $this->client->request( 'POST', '/api/users/avatar', @@ -132,11 +116,8 @@ public function testApiCanUpdateAndDeleteCurrentUserCover(): void $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); - // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = bin2hex(random_bytes(32)); - copy($this->kibbyPath, $tmpPath.'.png'); - $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); - $expectedPath = $imageManager->getFilePath($image->getFilename()); + $image = $this->getKibbyImageUpload(); + $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); $this->client->request( 'POST', '/api/users/cover', diff --git a/tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php b/tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php index 5301ac6d65..47421b4796 100644 --- a/tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php +++ b/tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php @@ -9,12 +9,6 @@ class EntryCommentCreateControllerTest extends WebTestCase { - public function setUp(): void - { - parent::setUp(); - $this->kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; - } - public function testUserCanCreateEntryComment(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); diff --git a/tests/Functional/Controller/Entry/EntryCreateControllerTest.php b/tests/Functional/Controller/Entry/EntryCreateControllerTest.php index d01655d22d..06e666bb57 100644 --- a/tests/Functional/Controller/Entry/EntryCreateControllerTest.php +++ b/tests/Functional/Controller/Entry/EntryCreateControllerTest.php @@ -9,14 +9,6 @@ class EntryCreateControllerTest extends WebTestCase { - public string $kibbyPath; - - public function setUp(): void - { - parent::setUp(); - $this->kibbyPath = \dirname(__FILE__, 4).'/assets/kibby_emoji.png'; - } - public function testUserCanCreateEntry() { $this->client->loginUser($this->getUserByUsername('user')); diff --git a/tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php b/tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php index 1531a78ede..153bf8d229 100644 --- a/tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php +++ b/tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php @@ -9,12 +9,6 @@ class PostCommentCreateControllerTest extends WebTestCase { - public function setUp(): void - { - parent::setUp(); - $this->kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; - } - public function testUserCanCreatePostComment(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); diff --git a/tests/Functional/Controller/Post/PostCreateControllerTest.php b/tests/Functional/Controller/Post/PostCreateControllerTest.php index 2a02da5126..1df78d89a3 100644 --- a/tests/Functional/Controller/Post/PostCreateControllerTest.php +++ b/tests/Functional/Controller/Post/PostCreateControllerTest.php @@ -9,12 +9,6 @@ class PostCreateControllerTest extends WebTestCase { - public function setUp(): void - { - parent::setUp(); - $this->kibbyPath = \dirname(__FILE__, 4).'/assets/kibby_emoji.png'; - } - public function testUserCanCreatePost(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); diff --git a/tests/Functional/Controller/User/Profile/UserEditControllerTest.php b/tests/Functional/Controller/User/Profile/UserEditControllerTest.php index 50fc7f9c66..9932e869a1 100644 --- a/tests/Functional/Controller/User/Profile/UserEditControllerTest.php +++ b/tests/Functional/Controller/User/Profile/UserEditControllerTest.php @@ -11,14 +11,6 @@ class UserEditControllerTest extends WebTestCase { - public string $kibbyPath; - - public function setUp(): void - { - parent::setUp(); - $this->kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; - } - public function testUserCanSeeSettingsLink(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index ec6adab2c4..e205d1f3b2 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -193,6 +193,7 @@ abstract class WebTestCase extends BaseWebTestCase protected DeliverHandler $deliverHandler; protected string $kibbyPath; + protected string $imageUploadTmpDir; public function setUp(): void { @@ -202,6 +203,7 @@ public function setUp(): void $this->hashtags = new ArrayCollection(); $this->kibbyPath = \dirname(__FILE__).'/assets/kibby_emoji.png'; + $this->imageUploadTmpDir = \dirname($this->kibbyPath).'/copy/'; $this->client = static::createClient(); $this->testingApHttpClient = new TestingApHttpClient(); From ae8ab30c7114e3909544df846f38a051df29ed42 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Mon, 27 Jul 2026 23:10:56 +0000 Subject: [PATCH 12/16] Revert "fix creation of temporary images in tests" This reverts commit 7ef7b3c23cb9998cf7649fc3ae48c4c752e5ae2e. --- tests/FactoryTrait.php | 31 ++++------------- .../Comment/EntryCommentCreateApiTest.php | 33 +++++++++++++++---- .../Api/Entry/EntryCreateApiNewTest.php | 30 ++++++++++++----- .../Api/Entry/EntryCreateApiTest.php | 23 +++++++++---- .../Admin/MagazineDeleteIconApiTest.php | 11 ++++++- .../Admin/MagazineUpdateThemeApiTest.php | 24 +++++++++++--- .../Api/OAuth2/OAuth2ClientApiTest.php | 5 ++- .../Post/Comment/PostCommentCreateApiTest.php | 30 +++++++++++++---- .../Controller/Api/Post/PostCreateApiTest.php | 17 +++++++--- .../Api/User/UserUpdateImagesApiTest.php | 31 +++++++++++++---- .../EntryCommentCreateControllerTest.php | 6 ++++ .../Entry/EntryCreateControllerTest.php | 8 +++++ .../PostCommentCreateControllerTest.php | 6 ++++ .../Post/PostCreateControllerTest.php | 6 ++++ .../User/Profile/UserEditControllerTest.php | 8 +++++ tests/WebTestCase.php | 2 -- 16 files changed, 201 insertions(+), 70 deletions(-) diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php index 85ff82e88e..b9ff997414 100644 --- a/tests/FactoryTrait.php +++ b/tests/FactoryTrait.php @@ -634,43 +634,26 @@ public function getKibbyFlippedImageDto(): ImageDto return $this->getKibbyImageVariantDto('_flipped'); } - public function getKibbyImageUpload(): UploadedFile - { - return $this->getKibbyImageVariantUpload(''); - } - - public function getKibbyFlippedImageUpload(): UploadedFile + private function getKibbyImageVariantDto(string $suffix): ImageDto { - return $this->getKibbyImageVariantUpload('_flipped'); - } + $imageRepository = $this->imageRepository; + $imageFactory = $this->imageFactory; - private function getKibbyImageVariantUpload(string $suffix): UploadedFile - { - if (!file_exists($this->imageUploadTmpDir)) { - if (!mkdir($this->imageUploadTmpDir)) { + if (!file_exists(\dirname($this->kibbyPath).'/copy')) { + if (!mkdir(\dirname($this->kibbyPath).'/copy')) { throw new \Exception('The copy dir could not be created'); } } // Uploading a file appears to delete the file at the given path, so make a copy before upload - $tmpPath = $this->imageUploadTmpDir.bin2hex(random_bytes(32)).'.png'; + $tmpPath = \dirname($this->kibbyPath).'/copy/'.bin2hex(random_bytes(32)).'.png'; $srcPath = \dirname($this->kibbyPath).'/'.basename($this->kibbyPath, '.png').$suffix.'.png'; if (!file_exists($srcPath)) { throw new \Exception('For some reason the kibby image got deleted'); } copy($srcPath, $tmpPath); - - return new UploadedFile($tmpPath, 'kibby_emoji.png', 'image/png'); - } - - private function getKibbyImageVariantDto(string $suffix): ImageDto - { - $imageRepository = $this->imageRepository; - $imageFactory = $this->imageFactory; - - $imgUpload = $this->getKibbyImageVariantUpload($suffix); /** @var Image $image */ - $image = $imageRepository->findOrCreateFromUpload($imgUpload); + $image = $imageRepository->findOrCreateFromUpload(new UploadedFile($tmpPath, 'kibby_emoji.png', 'image/png')); self::assertNotNull($image); $image->altText = 'kibby'; $this->entityManager->persist($image); diff --git a/tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php b/tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php index 34380095f9..8facbd3430 100644 --- a/tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php +++ b/tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Functional\Controller\Api\Entry\Comment; use App\Tests\WebTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; class EntryCommentCreateApiTest extends WebTestCase { @@ -188,7 +189,10 @@ public function testApiCannotCreateImageCommentAnonymous(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/image", @@ -209,7 +213,10 @@ public function testApiCannotCreateImageCommentWithoutScope(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -237,7 +244,10 @@ public function testApiCanCreateImageComment(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); @@ -283,7 +293,10 @@ public function testApiCannotCreateImageCommentReplyAnonymous(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply/image", @@ -304,7 +317,10 @@ public function testApiCannotCreateImageCommentReplyWithoutScope(): void 'isAdult' => false, ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -334,8 +350,11 @@ public function testApiCanCreateImageCommentReply(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); - $resultingPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $resultingPath = $imageManager->getFilePath($image->getFilename()); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); diff --git a/tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php b/tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php index 2033159a2f..c7489ac8b6 100644 --- a/tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php +++ b/tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Functional\Controller\Api\Entry; use App\Tests\WebTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; class EntryCreateApiNewTest extends WebTestCase { @@ -253,10 +254,13 @@ public function testApiCanCreateLinkWithImageEntry(): void $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", @@ -319,7 +323,9 @@ public function testApiCannotCreateImageEntryAnonymous(): void 'isAdult' => false, ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/entries", @@ -340,7 +346,9 @@ public function testApiCannotCreateImageEntryWithoutScope(): void 'isAdult' => false, ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -372,10 +380,13 @@ public function testApiCanCreateImageEntry(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; @@ -445,10 +456,13 @@ public function testApiCanCreateImageWithBodyEntry(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; diff --git a/tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php b/tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php index 056e521739..b6a138872c 100644 --- a/tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php +++ b/tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Functional\Controller\Api\Entry; use App\Tests\WebTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; class EntryCreateApiTest extends WebTestCase { @@ -222,7 +223,9 @@ public function testApiCannotCreateImageEntryAnonymous(): void 'isAdult' => false, ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/image", @@ -243,7 +246,9 @@ public function testApiCannotCreateImageEntryWithoutScope(): void 'isAdult' => false, ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -275,10 +280,13 @@ public function testApiCanCreateImageEntry(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; @@ -348,10 +356,13 @@ public function testApiCanCreateImageEntryWithBody(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create'); $token = $codes['token_type'].' '.$codes['access_token']; diff --git a/tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php b/tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php index cf229c92e4..024c9d854d 100644 --- a/tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php +++ b/tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php @@ -8,9 +8,16 @@ use App\Tests\Functional\Controller\Api\Magazine\MagazineRetrieveApiTest; use App\Tests\WebTestCase; use PHPUnit\Framework\Attributes\Group; +use Symfony\Component\HttpFoundation\File\UploadedFile; class MagazineDeleteIconApiTest extends WebTestCase { + public function setUp(): void + { + parent::setUp(); + $this->kibbyPath = \dirname(__FILE__, 6).'/assets/kibby_emoji.png'; + } + public function testApiCannotDeleteMagazineIconAnonymous(): void { $magazine = $this->getMagazineByName('test'); @@ -66,7 +73,9 @@ public function testApiCanDeleteMagazineIcon(): void $magazine = $this->getMagazineByName('test'); - $upload = $this->getKibbyImageUpload(); + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $upload = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageRepository = $this->imageRepository; $image = $imageRepository->findOrCreateFromUpload($upload); diff --git a/tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php b/tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php index 6f14c81742..5769339794 100644 --- a/tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php +++ b/tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php @@ -7,11 +7,18 @@ use App\DTO\ModeratorDto; use App\Tests\Functional\Controller\Api\Magazine\MagazineRetrieveApiTest; use App\Tests\WebTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; class MagazineUpdateThemeApiTest extends WebTestCase { public const MAGAZINE_THEME_RESPONSE_KEYS = ['magazine', 'customCss', 'icon', 'banner']; + public function setUp(): void + { + parent::setUp(); + $this->kibbyPath = \dirname(__FILE__, 6).'/assets/kibby_emoji.png'; + } + public function testApiCannotUpdateMagazineThemeAnonymous(): void { $magazine = $this->getMagazineByName('test'); @@ -68,7 +75,10 @@ public function testApiCanUpdateMagazineThemeWithCustomCss(): void $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.tmp'); + $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); $customCss = 'a {background: red;}'; @@ -108,10 +118,13 @@ public function testApiCanUpdateMagazineThemeWithBackgroundImage(): void $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $backgroundImage = 'shape1'; @@ -150,7 +163,10 @@ public function testCanUpdateMagazineBanner(): void $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme'); $token = $codes['token_type'].' '.$codes['access_token']; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.tmp'); + $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'PUT', "/api/moderate/magazine/{$magazine->getId()}/banner", diff --git a/tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php b/tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php index e628af31d7..28e9c93d73 100644 --- a/tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php +++ b/tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php @@ -6,6 +6,7 @@ use App\Tests\WebTestCase; use PHPUnit\Framework\Attributes\Group; +use Symfony\Component\HttpFoundation\File\UploadedFile; class OAuth2ClientApiTest extends WebTestCase { @@ -151,7 +152,9 @@ public function testApiCanCreateWorkingClientWithImage(): void ], ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request('POST', '/api/client-with-logo', $requestData, files: ['uploadImage' => $image]); diff --git a/tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php b/tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php index 78c8a49fdb..d92a738b0b 100644 --- a/tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php +++ b/tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Functional\Controller\Api\Post\Comment; use App\Tests\WebTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; class PostCommentCreateApiTest extends WebTestCase { @@ -188,7 +189,9 @@ public function testApiCannotCreateImageCommentAnonymous(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/image", @@ -209,7 +212,10 @@ public function testApiCannotCreateImageCommentWithoutScope(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -237,7 +243,9 @@ public function testApiCanCreateImageComment(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); @@ -283,7 +291,9 @@ public function testApiCannotCreateImageCommentReplyAnonymous(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply/image", @@ -304,7 +314,10 @@ public function testApiCannotCreateImageCommentReplyWithoutScope(): void 'isAdult' => false, ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.tmp'); + $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -333,10 +346,13 @@ public function testApiCanCreateImageCommentReply(): void 'alt' => 'It\'s Kibby!', ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); self::createOAuth2AuthCodeClient(); $user = $this->getUserByUsername('user'); diff --git a/tests/Functional/Controller/Api/Post/PostCreateApiTest.php b/tests/Functional/Controller/Api/Post/PostCreateApiTest.php index 65945d3f82..231a7586be 100644 --- a/tests/Functional/Controller/Api/Post/PostCreateApiTest.php +++ b/tests/Functional/Controller/Api/Post/PostCreateApiTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Functional\Controller\Api\Post; use App\Tests\WebTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; class PostCreateApiTest extends WebTestCase { @@ -101,7 +102,9 @@ public function testApiCannotCreateImagePostAnonymous(): void 'isAdult' => false, ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', "/api/magazine/{$magazine->getId()}/posts/image", @@ -119,7 +122,10 @@ public function testApiCannotCreateImagePostWithoutScope(): void 'isAdult' => false, ]; - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); self::createOAuth2AuthCodeClient(); $this->client->loginUser($this->getUserByUsername('user')); @@ -148,10 +154,13 @@ public function testApiCanCreateImagePost(): void self::createOAuth2AuthCodeClient(); $this->client->loginUser($user); - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create'); $token = $codes['token_type'].' '.$codes['access_token']; diff --git a/tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php b/tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php index f0a5be6bc1..9f3d3d31b9 100644 --- a/tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php +++ b/tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php @@ -5,9 +5,18 @@ namespace App\Tests\Functional\Controller\Api\User; use App\Tests\WebTestCase; +use Symfony\Component\HttpFoundation\File\UploadedFile; class UserUpdateImagesApiTest extends WebTestCase { + public string $kibbyPath; + + public function setUp(): void + { + parent::setUp(); + $this->kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; + } + public function testApiCannotUpdateCurrentUserAvatarWithoutScope(): void { self::createOAuth2AuthCodeClient(); @@ -15,7 +24,9 @@ public function testApiCannotUpdateCurrentUserAvatarWithoutScope(): void $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', '/api/users/avatar', @@ -32,7 +43,9 @@ public function testApiCannotUpdateCurrentUserCoverWithoutScope(): void $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read'); - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + copy($this->kibbyPath, $this->kibbyPath.'.tmp'); + $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png'); $this->client->request( 'POST', '/api/users/cover', @@ -71,10 +84,13 @@ public function testApiCanUpdateAndDeleteCurrentUserAvatar(): void $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); - $image = $this->getKibbyImageUpload(); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); $imageManager = $this->imageManager; - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $this->client->request( 'POST', '/api/users/avatar', @@ -116,8 +132,11 @@ public function testApiCanUpdateAndDeleteCurrentUserCover(): void $this->client->loginUser($testUser); $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read'); - $image = $this->getKibbyImageUpload(); - $expectedPath = $imageManager->getFilePath($this->imageUploadTmpDir.$image->getFilename()); + // Uploading a file appears to delete the file at the given path, so make a copy before upload + $tmpPath = bin2hex(random_bytes(32)); + copy($this->kibbyPath, $tmpPath.'.png'); + $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png'); + $expectedPath = $imageManager->getFilePath($image->getFilename()); $this->client->request( 'POST', '/api/users/cover', diff --git a/tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php b/tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php index 47421b4796..5301ac6d65 100644 --- a/tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php +++ b/tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php @@ -9,6 +9,12 @@ class EntryCommentCreateControllerTest extends WebTestCase { + public function setUp(): void + { + parent::setUp(); + $this->kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; + } + public function testUserCanCreateEntryComment(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); diff --git a/tests/Functional/Controller/Entry/EntryCreateControllerTest.php b/tests/Functional/Controller/Entry/EntryCreateControllerTest.php index 06e666bb57..d01655d22d 100644 --- a/tests/Functional/Controller/Entry/EntryCreateControllerTest.php +++ b/tests/Functional/Controller/Entry/EntryCreateControllerTest.php @@ -9,6 +9,14 @@ class EntryCreateControllerTest extends WebTestCase { + public string $kibbyPath; + + public function setUp(): void + { + parent::setUp(); + $this->kibbyPath = \dirname(__FILE__, 4).'/assets/kibby_emoji.png'; + } + public function testUserCanCreateEntry() { $this->client->loginUser($this->getUserByUsername('user')); diff --git a/tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php b/tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php index 153bf8d229..1531a78ede 100644 --- a/tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php +++ b/tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php @@ -9,6 +9,12 @@ class PostCommentCreateControllerTest extends WebTestCase { + public function setUp(): void + { + parent::setUp(); + $this->kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; + } + public function testUserCanCreatePostComment(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); diff --git a/tests/Functional/Controller/Post/PostCreateControllerTest.php b/tests/Functional/Controller/Post/PostCreateControllerTest.php index 1df78d89a3..2a02da5126 100644 --- a/tests/Functional/Controller/Post/PostCreateControllerTest.php +++ b/tests/Functional/Controller/Post/PostCreateControllerTest.php @@ -9,6 +9,12 @@ class PostCreateControllerTest extends WebTestCase { + public function setUp(): void + { + parent::setUp(); + $this->kibbyPath = \dirname(__FILE__, 4).'/assets/kibby_emoji.png'; + } + public function testUserCanCreatePost(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); diff --git a/tests/Functional/Controller/User/Profile/UserEditControllerTest.php b/tests/Functional/Controller/User/Profile/UserEditControllerTest.php index 9932e869a1..50fc7f9c66 100644 --- a/tests/Functional/Controller/User/Profile/UserEditControllerTest.php +++ b/tests/Functional/Controller/User/Profile/UserEditControllerTest.php @@ -11,6 +11,14 @@ class UserEditControllerTest extends WebTestCase { + public string $kibbyPath; + + public function setUp(): void + { + parent::setUp(); + $this->kibbyPath = \dirname(__FILE__, 5).'/assets/kibby_emoji.png'; + } + public function testUserCanSeeSettingsLink(): void { $this->client->loginUser($this->getUserByUsername('JohnDoe')); diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index e205d1f3b2..ec6adab2c4 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -193,7 +193,6 @@ abstract class WebTestCase extends BaseWebTestCase protected DeliverHandler $deliverHandler; protected string $kibbyPath; - protected string $imageUploadTmpDir; public function setUp(): void { @@ -203,7 +202,6 @@ public function setUp(): void $this->hashtags = new ArrayCollection(); $this->kibbyPath = \dirname(__FILE__).'/assets/kibby_emoji.png'; - $this->imageUploadTmpDir = \dirname($this->kibbyPath).'/copy/'; $this->client = static::createClient(); $this->testingApHttpClient = new TestingApHttpClient(); From 865c8b92d71e89c194154c881ab06474e56fe2b5 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Fri, 31 Jul 2026 17:57:11 +0000 Subject: [PATCH 13/16] apply review comments --- .../Api/Tag/TagBlockApiController.php | 3 +-- .../Api/Tag/TagSubscriptionApiController.php | 3 +-- .../Tag/TagCommentFrontController.php | 7 ++++- .../Tag/TagEntryFrontController.php | 7 ++++- .../Tag/TagPeopleFrontController.php | 26 ++++++++++++++----- src/Controller/Tag/TagPostFrontController.php | 7 ++++- src/Entity/HashtagSubscription.php | 2 +- src/Repository/EntryRepository.php | 8 ++++++ src/Repository/PostRepository.php | 14 +++++++--- src/Service/TagManager.php | 16 +++++++----- translations/messages.en.yaml | 18 ++++++++----- 11 files changed, 80 insertions(+), 31 deletions(-) diff --git a/src/Controller/Api/Tag/TagBlockApiController.php b/src/Controller/Api/Tag/TagBlockApiController.php index 47b9c4b838..3e39cfc6bb 100644 --- a/src/Controller/Api/Tag/TagBlockApiController.php +++ b/src/Controller/Api/Tag/TagBlockApiController.php @@ -4,7 +4,6 @@ namespace App\Controller\Api\Tag; -use App\DTO\DomainDto; use App\DTO\HashtagResponseDto; use App\Entity\Hashtag; use App\Entity\HashtagBlock; @@ -79,7 +78,7 @@ public function block( #[OA\Response( response: 200, description: 'Hashtag unblocked', - content: new Model(type: DomainDto::class), + content: new Model(type: HashtagResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), diff --git a/src/Controller/Api/Tag/TagSubscriptionApiController.php b/src/Controller/Api/Tag/TagSubscriptionApiController.php index 6e0bca08b1..2498a2267f 100644 --- a/src/Controller/Api/Tag/TagSubscriptionApiController.php +++ b/src/Controller/Api/Tag/TagSubscriptionApiController.php @@ -4,7 +4,6 @@ namespace App\Controller\Api\Tag; -use App\DTO\DomainDto; use App\DTO\HashtagResponseDto; use App\Entity\Hashtag; use App\Entity\HashtagSubscription; @@ -79,7 +78,7 @@ public function subscribe( #[OA\Response( response: 200, description: 'Hashtag unsubscribed', - content: new Model(type: DomainDto::class), + content: new Model(type: HashtagResponseDto::class), headers: [ new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), diff --git a/src/Controller/Tag/TagCommentFrontController.php b/src/Controller/Tag/TagCommentFrontController.php index 862a1f9103..56f464b274 100644 --- a/src/Controller/Tag/TagCommentFrontController.php +++ b/src/Controller/Tag/TagCommentFrontController.php @@ -25,12 +25,17 @@ public function __construct( public function __invoke(string $name, ?string $sortBy, ?string $time, Request $request): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) - ->setTag($this->tagManager->transliterate(strtolower($name))); + ->setTag($tag); + + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); $params = [ + 'hashtag' => $hashtag, 'comments' => $this->repository->findByCriteria($criteria), 'tag' => $name, 'counts' => $this->tagRepository->getCounts($name), diff --git a/src/Controller/Tag/TagEntryFrontController.php b/src/Controller/Tag/TagEntryFrontController.php index c9ab5dfb6f..c7a9e23b6f 100644 --- a/src/Controller/Tag/TagEntryFrontController.php +++ b/src/Controller/Tag/TagEntryFrontController.php @@ -27,17 +27,22 @@ public function __construct( public function __invoke(?string $name, ?string $sortBy, ?string $time, ?string $type, Request $request): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + $criteria = new EntryPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) ->setType($criteria->resolveType($type)) - ->setTag($this->tagManager->transliterate(strtolower($name))); + ->setTag($tag); $method = $criteria->resolveSort($sortBy); $listing = $this->$method($criteria); + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + return $this->render( 'tag/front.html.twig', [ + 'hashtag' => $hashtag, 'tag' => $name, 'entries' => $listing, 'counts' => $this->tagRepository->getCounts($name), diff --git a/src/Controller/Tag/TagPeopleFrontController.php b/src/Controller/Tag/TagPeopleFrontController.php index f52334a6d8..a187af447e 100644 --- a/src/Controller/Tag/TagPeopleFrontController.php +++ b/src/Controller/Tag/TagPeopleFrontController.php @@ -9,6 +9,8 @@ use App\Repository\PostRepository; use App\Repository\TagRepository; use App\Service\PeopleManager; +use App\Service\TagExtractor; +use App\Service\TagManager; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,6 +18,7 @@ class TagPeopleFrontController extends AbstractController { public function __construct( private readonly PeopleManager $manager, + private readonly TagExtractor $tagManager, private readonly TagRepository $tagRepository, private readonly MagazineRepository $magazineRepository, ) { @@ -28,16 +31,25 @@ public function __invoke( PostRepository $repository, Request $request, ): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + + $magazines = array_filter( + $this->magazineRepository->findByActivity(), + fn ($val) => 'random' !== $val->name + ); + $localPeople = $this->manager->general(); + $generalPeople = $this->manager->general(true); + $counts = $this->tagRepository->getCounts($tag); + return $this->render( 'tag/people.html.twig', [ + 'hashtag' => $hashtag, 'tag' => $name, - 'magazines' => array_filter( - $this->magazineRepository->findByActivity(), - fn ($val) => 'random' !== $val->name - ), - 'local' => $this->manager->general(), - 'federated' => $this->manager->general(true), - 'counts' => $this->tagRepository->getCounts($name), + 'magazines' => $magazines, + 'local' => $localPeople, + 'federated' => $generalPeople, + 'counts' => $counts, ] ); } diff --git a/src/Controller/Tag/TagPostFrontController.php b/src/Controller/Tag/TagPostFrontController.php index ccf65e69e2..8611245ea8 100644 --- a/src/Controller/Tag/TagPostFrontController.php +++ b/src/Controller/Tag/TagPostFrontController.php @@ -29,16 +29,21 @@ public function __invoke( PostRepository $repository, Request $request, ): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + $criteria = new PostPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) - ->setTag($this->tagManager->transliterate(strtolower($name))); + ->setTag($tag); $posts = $repository->findByCriteria($criteria); + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + return $this->render( 'tag/posts.html.twig', [ + 'hashtag' => $hashtag, 'tag' => $name, 'posts' => $posts, 'counts' => $this->tagRepository->getCounts($name), diff --git a/src/Entity/HashtagSubscription.php b/src/Entity/HashtagSubscription.php index 189dd7c6d2..e03b6591dd 100644 --- a/src/Entity/HashtagSubscription.php +++ b/src/Entity/HashtagSubscription.php @@ -27,7 +27,7 @@ class HashtagSubscription #[ManyToOne(targetEntity: User::class, inversedBy: 'subscribedHashtags')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public ?User $user; - #[ManyToOne(targetEntity: Hashtag::class)] + #[ManyToOne(targetEntity: Hashtag::class, inversedBy: 'subscriptions')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] public ?Hashtag $hashtag; #[Id] diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 4540c4ff51..09fe3a1d29 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -13,6 +13,7 @@ use App\Entity\DomainSubscription; use App\Entity\Entry; use App\Entity\EntryFavourite; +use App\Entity\HashtagBlock; use App\Entity\HashtagLink; use App\Entity\Magazine; use App\Entity\MagazineBlock; @@ -238,6 +239,13 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder ); } + $qb->andWhere( + 'NOT EXISTS (' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hl ON hb.hashtag = hl.hashtag ' + .'WHERE hl.entry = e AND hb.user = :blocker' + .')' + ); + $qb->setParameter('blocker', $user); } diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index d4726a3451..8960677ec4 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -9,6 +9,7 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\HashtagBlock; use App\Entity\HashtagLink; use App\Entity\Magazine; use App\Entity\MagazineBlock; @@ -205,12 +206,19 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder $qb->andWhere( 'NOT EXISTS (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker AND ub.blocked = p.user)' ); - $qb->setParameter('blocker', $user); $qb->andWhere( - 'NOT EXISTS (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :magazineBlocker AND mb.magazine = p.magazine)' + 'NOT EXISTS (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :blocker AND mb.magazine = p.magazine)' + ); + + $qb->andWhere( + 'NOT EXISTS (' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hl ON hb.hashtag = hl.hashtag ' + .'WHERE hl.post = p AND hb.user = :blocker' + .')' ); - $qb->setParameter('magazineBlocker', $user); + + $qb->setParameter('blocker', $user); } if (!$user || $user->hideAdult) { diff --git a/src/Service/TagManager.php b/src/Service/TagManager.php index 426ada4bc9..2b7f99b7bf 100644 --- a/src/Service/TagManager.php +++ b/src/Service/TagManager.php @@ -183,28 +183,30 @@ public function isAnyTagBanned(?array $tags): bool return false; } - public function subscribe(User $user, Hashtag $tag): void + public function subscribe(User $user, Hashtag $hashtag): void { - $user->unblockHashtag($tag); + $this->unblock($user, $hashtag); - $tag->subscribe($user); + $hashtag->subscribe($user); $this->entityManager->flush(); - $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($tag, $user, true)); + $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($hashtag, $user, true)); } - public function unsubscribe(User $user, Hashtag $tag): void + public function unsubscribe(User $user, Hashtag $hashtag): void { - $tag->unsubscribe($user); + $hashtag->unsubscribe($user); $this->entityManager->flush(); - $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($tag, $user, false)); + $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($hashtag, $user, false)); } public function block(User $user, Hashtag $hashtag): void { + $this->unsubscribe($user, $hashtag); + $user->blockHashtag($hashtag); $this->entityManager->flush(); diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 03c8f73a92..edfff8dfd5 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -566,16 +566,22 @@ oauth2.grant.delete.general: Delete any of your threads, posts, or comments. oauth2.grant.report.general: Report threads, posts, or comments. oauth2.grant.vote.general: Upvote, downvote, or boost threads, posts, or comments. -oauth2.grant.subscribe.general: Subscribe or follow any magazine, domain, or +oauth2.grant.subscribe.general: Subscribe or follow any magazine, domain, hashtag, or user, and view the magazines, domains, and users you subscribe to. -oauth2.grant.block.general: Block or unblock any magazine, domain, or user, and +oauth2.grant.block.general: Block or unblock any magazine, domain, hashtag, or user, and view the magazines, domains, and users you have blocked. oauth2.grant.domain.all: Subscribe to or block domains, and view the domains you - subscribe to or block. + subscribed to or have blocked. oauth2.grant.domain.subscribe: Subscribe or unsubscribe to domains and view the - domains you subscribe to. + domains you subscribed to. oauth2.grant.domain.block: Block or unblock domains and view the domains you have blocked. +oauth2.grant.hashtag.all: Subscribe to or block hashtags, and view the hashtags you + subscribed to or have blocked. +oauth2.grant.hashtag.subscribe: Subscribe or unsubscribe to hashtags and view the + hashtags you subscribed to. +oauth2.grant.hashtag.block: Block or unblock hashtags and view the hashtags you + have blocked. oauth2.grant.entry.all: Create, edit, or delete your threads, and vote, boost, or report any thread. oauth2.grant.entry.create: Create new threads. @@ -592,9 +598,9 @@ oauth2.grant.entry_comment.vote: Upvote, boost, or downvote any comment in a thread. oauth2.grant.entry_comment.report: Report any comment in a thread. oauth2.grant.magazine.all: Subscribe to or block magazines, and view the - magazines you subscribe to or block. + magazines you subscribed to or have blocked. oauth2.grant.magazine.subscribe: Subscribe or unsubscribe to magazines and view - the magazines you subscribe to. + the magazines you subscribed to. oauth2.grant.magazine.block: Block or unblock magazines and view the magazines you have blocked. oauth2.grant.post.all: Create, edit, or delete your microblogs, and vote, boost, From de72c0c7ba46b5387a270a639d13f4ffcb0cae5a Mon Sep 17 00:00:00 2001 From: blued_gear Date: Fri, 31 Jul 2026 21:10:46 +0000 Subject: [PATCH 14/16] optionally show comments with subscribed hashtag --- migrations/Version20260726182822.php | 2 + .../Api/Combined/CombinedRetrieveApi.php | 57 ++++++++++++++++--- src/Controller/Api/Post/PostsRetrieveApi.php | 11 +++- src/Controller/Entry/EntryFrontController.php | 1 + src/DTO/UserSettingsDto.php | 5 ++ src/Entity/User.php | 2 + src/Form/UserSettingsType.php | 4 ++ src/Repository/ContentRepository.php | 18 +++++- src/Repository/Criteria.php | 1 + src/Repository/EntryCommentRepository.php | 7 ++- src/Repository/EntryRepository.php | 7 ++- src/Repository/PostCommentRepository.php | 16 ++++++ src/Repository/PostRepository.php | 7 ++- src/Service/UserSettingsManager.php | 2 + templates/user/settings/general.html.twig | 7 +++ .../Controller/Api/User/UserUpdateApiTest.php | 2 + .../Service/Hashtag/TagSubscriptionTest.php | 55 +++++++++++++++--- translations/messages.en.yaml | 2 + 18 files changed, 183 insertions(+), 23 deletions(-) diff --git a/migrations/Version20260726182822.php b/migrations/Version20260726182822.php index 7ac3d7bdc4..e52ed26aa9 100644 --- a/migrations/Version20260726182822.php +++ b/migrations/Version20260726182822.php @@ -23,6 +23,7 @@ public function up(Schema $schema): void $this->addSql('CREATE UNIQUE INDEX hashtag_subscription_idx ON hashtag_subscription (user_id, hashtag_id)'); $this->addSql('ALTER TABLE hashtag_subscription ADD CONSTRAINT FK_5814F278A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); $this->addSql('ALTER TABLE hashtag_subscription ADD CONSTRAINT FK_5814F278FB34EF56 FOREIGN KEY (hashtag_id) REFERENCES hashtag (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE "user" ADD show_comments_of_subscribed_hashtags BOOLEAN DEFAULT false NOT NULL'); } public function down(Schema $schema): void @@ -31,5 +32,6 @@ public function down(Schema $schema): void $this->addSql('ALTER TABLE hashtag_subscription DROP CONSTRAINT FK_5814F278A76ED395'); $this->addSql('ALTER TABLE hashtag_subscription DROP CONSTRAINT FK_5814F278FB34EF56'); $this->addSql('DROP TABLE hashtag_subscription'); + $this->addSql('ALTER TABLE "user" DROP show_comments_of_subscribed_hashtags'); } } diff --git a/src/Controller/Api/Combined/CombinedRetrieveApi.php b/src/Controller/Api/Combined/CombinedRetrieveApi.php index 67a004d296..2e1c876461 100644 --- a/src/Controller/Api/Combined/CombinedRetrieveApi.php +++ b/src/Controller/Api/Combined/CombinedRetrieveApi.php @@ -129,6 +129,12 @@ class CombinedRetrieveApi extends BaseApi in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] public function collection( RateLimiterFactoryInterface $apiReadLimiter, @@ -142,9 +148,10 @@ public function collection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria(null, $p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); + $criteria = $this->getCriteria(null, $p, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, null); $content = $contentRepository->findByCriteria($criteria); @@ -248,6 +255,12 @@ public function collection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] #[\Nelmio\ApiDocBundle\Attribute\Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] @@ -264,9 +277,10 @@ public function userCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria(null, $p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, $collectionType); + $criteria = $this->getCriteria(null, $p, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, $collectionType); $content = $contentRepository->findByCriteria($criteria); @@ -370,6 +384,12 @@ public function userCollection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] public function cursorCollection( RateLimiterFactoryInterface $apiReadLimiter, @@ -383,10 +403,11 @@ public function cursorCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, SqlHelpers $sqlHelpers, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria(null, 1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); + $criteria = $this->getCriteria(null, 1, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, null); $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor); $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null; @@ -492,6 +513,12 @@ public function cursorCollection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] #[\Nelmio\ApiDocBundle\Attribute\Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] @@ -508,10 +535,11 @@ public function cursorUserCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, SqlHelpers $sqlHelpers, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria(null, 1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, $collectionType); + $criteria = $this->getCriteria(null, 1, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, $collectionType); $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor); $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null; @@ -617,6 +645,12 @@ public function cursorUserCollection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] public function magazineCollection( RateLimiterFactoryInterface $apiReadLimiter, @@ -632,9 +666,10 @@ public function magazineCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria($magazine, $p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); + $criteria = $this->getCriteria($magazine, $p, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, null); $content = $contentRepository->findByCriteria($criteria); @@ -744,6 +779,12 @@ public function magazineCollection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] public function cursorMagazineCollection( RateLimiterFactoryInterface $apiReadLimiter, @@ -759,10 +800,11 @@ public function cursorMagazineCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, SqlHelpers $sqlHelpers, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria($magazine, 1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); + $criteria = $this->getCriteria($magazine, 1, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, null); $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor); $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null; @@ -771,7 +813,7 @@ public function cursorMagazineCollection( return $this->serializeContentCursored($content, $headers); } - private function getCriteria(?Magazine $magazine, ?int $p, Security $security, ?string $sort, ?string $time, ?string $federation, ?bool $includeBoosts, ?int $perPage, SqlHelpers $sqlHelpers, ?string $collectionType): ContentPageView + private function getCriteria(?Magazine $magazine, ?int $p, Security $security, ?string $sort, ?string $time, ?string $federation, ?bool $includeBoosts, ?bool $includeCommentsWithSubHashtag, ?int $perPage, SqlHelpers $sqlHelpers, ?string $collectionType): ContentPageView { $criteria = new ContentPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; @@ -784,6 +826,7 @@ private function getCriteria(?Magazine $magazine, ?int $p, Security $security, ? $user = $security->getUser(); if ($user instanceof User) { $criteria->includeBoosts = Criteria::SORT_NEW === $criteria->sortOption && ($includeBoosts ?? $user->showBoostsOfFollowing); + $criteria->includeCommentsWithSubscribedHashtag = $includeCommentsWithSubHashtag ?? $user->showCommentsOfSubscribedHashtags; $criteria->fetchCachedItems($sqlHelpers, $user); } diff --git a/src/Controller/Api/Post/PostsRetrieveApi.php b/src/Controller/Api/Post/PostsRetrieveApi.php index 277974de7b..4448ef9ad5 100644 --- a/src/Controller/Api/Post/PostsRetrieveApi.php +++ b/src/Controller/Api/Post/PostsRetrieveApi.php @@ -442,6 +442,12 @@ public function subscribed( in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'post')] #[Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] @@ -456,9 +462,12 @@ public function subscribedWithBoosts( #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $user = $this->getUserOrThrow(); + $criteria = new PostPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); @@ -467,11 +476,11 @@ public function subscribedWithBoosts( $criteria->subscribed = true; $criteria->includeBoosts = Criteria::SORT_NEW === $criteria->sortOption; + $criteria->includeCommentsWithSubscribedHashtag = $includeCommentsWithSubscribedHashtag ?? $user->showCommentsOfSubscribedHashtags; $criteria->setContent(Criteria::CONTENT_MICROBLOG); $this->handleLanguageCriteria($criteria); - $user = $this->getUserOrThrow(); $criteria->fetchCachedItems($sqlHelpers, $user); $posts = $repository->findByCriteria($criteria); diff --git a/src/Controller/Entry/EntryFrontController.php b/src/Controller/Entry/EntryFrontController.php index 90f756e3d6..290f9e45f5 100644 --- a/src/Controller/Entry/EntryFrontController.php +++ b/src/Controller/Entry/EntryFrontController.php @@ -217,6 +217,7 @@ private function setUserPreferences(?User $user, Criteria &$criteria): void } $criteria->includeBoosts = Criteria::SORT_NEW === $criteria->sortOption && $user->showBoostsOfFollowing; + $criteria->includeCommentsWithSubscribedHashtag = $user->showCommentsOfSubscribedHashtags; if (0 < \count($user->preferredLanguages)) { $criteria->languages = $user->preferredLanguages; diff --git a/src/DTO/UserSettingsDto.php b/src/DTO/UserSettingsDto.php index 49a2bcd5e8..62bb7b0001 100644 --- a/src/DTO/UserSettingsDto.php +++ b/src/DTO/UserSettingsDto.php @@ -33,6 +33,7 @@ public function __construct( #[OA\Property(type: 'string', enum: EntryCommentPageView::SORT_OPTIONS)] public ?string $commentDefaultSort = null, public ?bool $showFollowingBoosts = null, + public ?bool $showCommentsOfSubscribedHashtags = null, #[OA\Property(type: 'array', items: new OA\Items(type: 'string'))] public ?array $featuredMagazines = null, #[OA\Property(type: 'array', items: new OA\Items(type: 'string'))] @@ -67,6 +68,8 @@ public function jsonSerialize(): mixed 'frontDefaultSort' => $this->frontDefaultSort, 'frontDefaultContent' => $this->frontDefaultContent, 'commentDefaultSort' => $this->commentDefaultSort, + 'showFollowingBoosts' => $this->showFollowingBoosts, + 'showCommentsOfSubscribedHashtags' => $this->showCommentsOfSubscribedHashtags, 'featuredMagazines' => $this->featuredMagazines, 'preferredLanguages' => $this->preferredLanguages, 'customCss' => $this->customCss, @@ -94,6 +97,8 @@ public function mergeIntoDto(UserSettingsDto $dto): UserSettingsDto $dto->homepage = $this->homepage ?? $dto->homepage; $dto->frontDefaultSort = $this->frontDefaultSort ?? $dto->frontDefaultSort; $dto->commentDefaultSort = $this->commentDefaultSort ?? $dto->commentDefaultSort; + $dto->showFollowingBoosts = $this->showFollowingBoosts ?? $dto->showFollowingBoosts; + $dto->showCommentsOfSubscribedHashtags = $this->showCommentsOfSubscribedHashtags ?? $dto->showCommentsOfSubscribedHashtags; $dto->featuredMagazines = $this->featuredMagazines ?? $dto->featuredMagazines; $dto->preferredLanguages = $this->preferredLanguages ?? $dto->preferredLanguages; $dto->customCss = $this->customCss ?? $dto->customCss; diff --git a/src/Entity/User.php b/src/Entity/User.php index 1de74e73f5..d1f2e14184 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -112,6 +112,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public string $homepage = self::HOMEPAGE_ALL; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $showBoostsOfFollowing = false; + #[Column(type: 'boolean', nullable: false, options: ['default' => false])] + public bool $showCommentsOfSubscribedHashtags = false; #[Column(type: 'enumSortOptions', nullable: false, options: ['default' => ESortOptions::Hot->value])] public string $frontDefaultSort = ESortOptions::Hot->value; #[Column(type: 'enumFrontContentOptions', nullable: true)] diff --git a/src/Form/UserSettingsType.php b/src/Form/UserSettingsType.php index 27b37f541c..a9fc4e4865 100644 --- a/src/Form/UserSettingsType.php +++ b/src/Form/UserSettingsType.php @@ -86,6 +86,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'show_boost_following_help', ]) + ->add('showCommentsOfSubscribedHashtags', CheckboxType::class, [ + 'required' => false, + 'help' => 'show_hashtag_sub_comments_help', + ]) ->add('discoverable', CheckboxType::class, [ 'required' => false, 'help' => 'user_discoverable_help', diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index 159959d1a2..172281f150 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -224,12 +224,23 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use $subClauseEntry = str_replace('%hl_type%', 'entry', $subClauseEntry); if ($criteria->includeBoosts) { - // TODO should comments with a subscribed hashtag be included too? $repliesCommonWhere = 'c.user_id = :loggedInUser' .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' : ' OR c.user_id IN (:cachedUserFollows)'); + if($criteria->includeCommentsWithSubscribedHashtag) { + // only include the subclause if there are (/ might be) subscriptions + if (null === $criteria->cachedUserSubscribedHashtags || !empty($criteria->cachedUserSubscribedHashtags)) { + if (null === $criteria->cachedUserSubscribedHashtags) { + $repliesCommonWhere .= ' OR EXISTS (SELECT 1 FROM hashtag_subscription hs INNER JOIN hashtag_link hl ON hs.hashtag_id = hl.hashtag_id WHERE hs.user_id = :loggedInUser AND hl.%hl_type%_id = c.id)'; + } else { + $repliesCommonWhere .= ' OR EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.%hl_type%_id = c.id AND hl.hashtag_id IN (:cachedUserSubscribedHashtags))'; + $parameters['cachedUserSubscribedHashtags'] = $criteria->cachedUserSubscribedHashtags; + } + } + } + $subClauseEntryComment = $repliesCommonWhere. (null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN entry_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : @@ -239,6 +250,11 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : ' OR EXISTS (SELECT 1 FROM post_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); + if($criteria->includeCommentsWithSubscribedHashtag) { + $subClauseEntryComment = str_replace('%hl_type%', 'entry_comment', $subClauseEntryComment); + $subClausePostComment = str_replace('%hl_type%', 'post_comment', $subClausePostComment); + } + $subClausePost = $subClausePost .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_vote v ON uf.following_id = v.user_id WHERE c.id = v.post_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : diff --git a/src/Repository/Criteria.php b/src/Repository/Criteria.php index 15296cd6b1..ab0c7c5751 100644 --- a/src/Repository/Criteria.php +++ b/src/Repository/Criteria.php @@ -95,6 +95,7 @@ abstract class Criteria public bool $moderated = false; public bool $favourite = false; public bool $includeBoosts = false; + public bool $includeCommentsWithSubscribedHashtag = false; public ?string $tag = null; public ?string $domain = null; public ?array $languages = null; diff --git a/src/Repository/EntryCommentRepository.php b/src/Repository/EntryCommentRepository.php index 510ac09c5d..41078caa46 100644 --- a/src/Repository/EntryCommentRepository.php +++ b/src/Repository/EntryCommentRepository.php @@ -16,6 +16,7 @@ use App\Entity\EntryCommentFavourite; use App\Entity\HashtagBlock; use App\Entity\HashtagLink; +use App\Entity\HashtagSubscription; use App\Entity\Image; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; @@ -182,7 +183,7 @@ private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): Quer ->setParameter('tag', $criteria->tag); } - if ($criteria->subscribed) { + if ($user && $criteria->subscribed) { $qb->andWhere( 'c.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :follower) OR @@ -190,7 +191,9 @@ private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): Quer OR c.user = :follower OR - ce.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :follower)' + ce.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :follower) + OR + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hl ON hs.hashtag = hl.hashtag WHERE hl.entryComment = c AND hs.user = :follower)' ); $qb->setParameter('follower', $user); } diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 09fe3a1d29..4ea167ef69 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -15,6 +15,7 @@ use App\Entity\EntryFavourite; use App\Entity\HashtagBlock; use App\Entity\HashtagLink; +use App\Entity\HashtagSubscription; use App\Entity\Magazine; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; @@ -197,7 +198,7 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING); } - if ($criteria->subscribed) { + if ($user && $criteria->subscribed) { $qb->andWhere( 'e.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user) OR @@ -205,7 +206,9 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder OR e.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :user) OR - e.user = :user' + e.user = :user + OR + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hl ON hs.hashtag = hl.hashtag WHERE hl.entry = e AND hs.user = :user)' ) ->setParameter('user', $this->security->getUser()); } diff --git a/src/Repository/PostCommentRepository.php b/src/Repository/PostCommentRepository.php index 0e6c1d7620..b4b01877bc 100644 --- a/src/Repository/PostCommentRepository.php +++ b/src/Repository/PostCommentRepository.php @@ -9,9 +9,12 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\DomainSubscription; use App\Entity\HashtagBlock; use App\Entity\HashtagLink; +use App\Entity\HashtagSubscription; use App\Entity\Image; +use App\Entity\MagazineSubscription; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; @@ -161,6 +164,19 @@ private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): void ->setParameter('tag', $criteria->tag); } + if($user && $criteria->subscribed) { + $qb->andWhere( + 'c.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :follower) + OR + c.user IN (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :follower) + OR + c.user = :follower + OR + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hl ON hs.hashtag = hl.hashtag WHERE hl.postComment = c AND hs.user = :follower)' + ); + $qb->setParameter('follower', $user); + } + if ($user && !$criteria->moderated) { $qb->andWhere( 'c.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)' diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 8960677ec4..822190807d 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -11,6 +11,7 @@ use App\Entity\Contracts\VisibilityInterface; use App\Entity\HashtagBlock; use App\Entity\HashtagLink; +use App\Entity\HashtagSubscription; use App\Entity\Magazine; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; @@ -172,13 +173,15 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder ->setParameter('tag', $criteria->tag); } - if ($criteria->subscribed) { + if ($user && $criteria->subscribed) { $qb->andWhere( 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) OR EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user) OR - p.user = :user' + p.user = :user + OR + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hl ON hs.hashtag = hl.hashtag WHERE hl.post = p AND hs.user = :user)' ); $qb->setParameter('user', $this->security->getUser()); } diff --git a/src/Service/UserSettingsManager.php b/src/Service/UserSettingsManager.php index d5e8d7af44..87858cf923 100644 --- a/src/Service/UserSettingsManager.php +++ b/src/Service/UserSettingsManager.php @@ -32,6 +32,7 @@ public function createDto(User $user): UserSettingsDto $user->frontDefaultSort, $user->commentDefaultSort, $user->showBoostsOfFollowing, + $user->showCommentsOfSubscribedHashtags, $user->featuredMagazines, $user->preferredLanguages, $user->customCss, @@ -56,6 +57,7 @@ public function update(User $user, UserSettingsDto $dto): void $user->frontDefaultSort = $dto->frontDefaultSort; $user->commentDefaultSort = $dto->commentDefaultSort; $user->showBoostsOfFollowing = $dto->showFollowingBoosts ?? false; + $user->showCommentsOfSubscribedHashtags = $dto->showCommentsOfSubscribedHashtags ?? false; $user->hideAdult = $dto->hideAdult; $user->showProfileSubscriptions = $dto->showProfileSubscriptions; $user->showProfileFollowings = $dto->showProfileFollowings; diff --git a/templates/user/settings/general.html.twig b/templates/user/settings/general.html.twig index cb3d297d93..3ed90272c5 100644 --- a/templates/user/settings/general.html.twig +++ b/templates/user/settings/general.html.twig @@ -38,6 +38,13 @@
    {{ form_help(form.showFollowingBoosts) }}
    +
    + {{ form_label(form.showCommentsOfSubscribedHashtags, 'show_hashtag_sub_comments_label') }} + {{ form_widget(form.showCommentsOfSubscribedHashtags) }} +
    +
    + {{ form_help(form.showCommentsOfSubscribedHashtags) }} +

    {{ 'writing'|trans }}

    {{ form_row(form.addMentionsEntries, {label: 'add_mentions_entries', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.addMentionsPosts, {label: 'add_mentions_posts', row_attr: {class: 'checkbox'}}) }} diff --git a/tests/Functional/Controller/Api/User/UserUpdateApiTest.php b/tests/Functional/Controller/Api/User/UserUpdateApiTest.php index 9e109bb27f..d77998a120 100644 --- a/tests/Functional/Controller/Api/User/UserUpdateApiTest.php +++ b/tests/Functional/Controller/Api/User/UserUpdateApiTest.php @@ -207,6 +207,7 @@ public function testApiCannotUpdateCurrentUserSettingsWithoutScope(): void Criteria::SORT_HOT, Criteria::SORT_HOT, false, + false, ['test'], ['en'], directMessageSetting: EDirectMessageSettings::Everyone->value, @@ -244,6 +245,7 @@ public function testApiCanUpdateCurrentUserSettings(): void Criteria::SORT_NEW, Criteria::SORT_TOP, false, + false, ['test'], ['en'], directMessageSetting: EDirectMessageSettings::FollowersOnly->value, diff --git a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php index e0864144e3..6657f59ee0 100644 --- a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php +++ b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php @@ -4,7 +4,9 @@ namespace App\Tests\Functional\Service\Hashtag; use App\Entity\Entry; +use App\Entity\EntryComment; use App\Entity\Post; +use App\Entity\PostComment; use App\PageView\EntryPageView; use App\Repository\Criteria; use App\Tests\WebTestCase; @@ -53,18 +55,18 @@ public function testSubscribedHashtagIsIncludedInCombinedWithCache() $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedWithCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); - $entryCommentShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); + $entryCommentNotShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); - $postCommentShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); + $postCommentNotShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); $this->setContentTime($entryHidden, $entryShowing, 2); - $this->setContentTime($entryCommentShowing, $entryShowing, 4); + $this->setContentTime($entryCommentNotShowing, $entryShowing, 4); $this->setContentTime($entryCommentHidden, $entryShowing, 6); $this->setContentTime($postShowing, $entryShowing, 8); $this->setContentTime($postHidden, $entryShowing, 10); - $this->setContentTime($postCommentShowing, $entryShowing, 12); + $this->setContentTime($postCommentNotShowing, $entryShowing, 12); $this->setContentTime($postCommentHidden, $entryShowing, 14); $this->tagManager->subscribe($user, $tag); @@ -96,18 +98,18 @@ public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedWithoutCache'); $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); - $entryCommentShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); + $entryCommentNotShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); - $postCommentShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); + $postCommentNotShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); $this->setContentTime($entryHidden, $entryShowing, 2); - $this->setContentTime($entryCommentShowing, $entryShowing, 4); + $this->setContentTime($entryCommentNotShowing, $entryShowing, 4); $this->setContentTime($entryCommentHidden, $entryShowing, 6); $this->setContentTime($postShowing, $entryShowing, 8); $this->setContentTime($postHidden, $entryShowing, 10); - $this->setContentTime($postCommentShowing, $entryShowing, 12); + $this->setContentTime($postCommentNotShowing, $entryShowing, 12); $this->setContentTime($postCommentHidden, $entryShowing, 14); $this->tagManager->subscribe($user, $tag); @@ -128,4 +130,41 @@ public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() self::assertSame($postShowing->getId(), $result[1]->getId()); self::assertCount(2, $result); } + + public function testSubscribedHashtagIsIncludedInCombinedComments() { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('interesting'); + + $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedComments'); + $entry = $this->createEntry('parent', $magazine, $contentCreator, body: 'some text'); + $entryCommentShowing = $this->createEntryComment('some text #interesting', $entry, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entry, $contentCreator); + $post = $this->createPost('parent', $magazine, $contentCreator); + $postCommentShowing = $this->createPostComment('some text #interesting', $post, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notInteresting', $post, $contentCreator); + $this->setContentTime($entryCommentShowing, $entry, 2); + $this->setContentTime($entryCommentHidden, $entry, 4); + $this->setContentTime($postCommentShowing, $entry, 6); + $this->setContentTime($postCommentHidden, $entry, 8); + + $this->tagManager->subscribe($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_OLD); + $criteria->subscribed = true; + $criteria->includeCommentsWithSubscribedHashtag = true; + $criteria->includeBoosts = true; + $criteria->perPage = 5; + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertInstanceOf(EntryComment::class, $result[0]); + self::assertSame($entryCommentShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(PostComment::class, $result[1]); + self::assertSame($postCommentShowing->getId(), $result[1]->getId()); + self::assertCount(2, $result); + } } diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index edfff8dfd5..c9ef0b03c1 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1086,6 +1086,8 @@ show_boost_following_label: Show boosted content in Microblog and Combined view show_boost_following_help: If this is enabled, threads, posts and comments boosted by you or users you follow will show up in the Combined view of your subscriptions and Microblog view. This will only have an effect when the sorting is set to 'Newest'. +show_hashtag_sub_comments_label: Show comments which contain a subscribed hashtag in Microblog and Combined view +show_hashtag_sub_comments_help: Requires inclusion of boosts to be enabled to be active in some views. delete_magazine_icon: Delete magazine icon flash_magazine_theme_icon_detached_success: Magazine icon deleted successfully delete_magazine_banner: Delete magazine banner From 8ade1a0a97a5bd8691e4efeacc20dceff660854a Mon Sep 17 00:00:00 2001 From: blued_gear Date: Fri, 31 Jul 2026 21:11:19 +0000 Subject: [PATCH 15/16] linter --- src/Controller/Tag/TagPeopleFrontController.php | 1 - src/Repository/ContentRepository.php | 4 ++-- src/Repository/PostCommentRepository.php | 3 +-- tests/Functional/Service/Hashtag/TagSubscriptionTest.php | 3 ++- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Controller/Tag/TagPeopleFrontController.php b/src/Controller/Tag/TagPeopleFrontController.php index a187af447e..6d0bb6da21 100644 --- a/src/Controller/Tag/TagPeopleFrontController.php +++ b/src/Controller/Tag/TagPeopleFrontController.php @@ -10,7 +10,6 @@ use App\Repository\TagRepository; use App\Service\PeopleManager; use App\Service\TagExtractor; -use App\Service\TagManager; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index 172281f150..3aafc1c499 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -229,7 +229,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' : ' OR c.user_id IN (:cachedUserFollows)'); - if($criteria->includeCommentsWithSubscribedHashtag) { + if ($criteria->includeCommentsWithSubscribedHashtag) { // only include the subclause if there are (/ might be) subscriptions if (null === $criteria->cachedUserSubscribedHashtags || !empty($criteria->cachedUserSubscribedHashtags)) { if (null === $criteria->cachedUserSubscribedHashtags) { @@ -250,7 +250,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?Use ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : ' OR EXISTS (SELECT 1 FROM post_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); - if($criteria->includeCommentsWithSubscribedHashtag) { + if ($criteria->includeCommentsWithSubscribedHashtag) { $subClauseEntryComment = str_replace('%hl_type%', 'entry_comment', $subClauseEntryComment); $subClausePostComment = str_replace('%hl_type%', 'post_comment', $subClausePostComment); } diff --git a/src/Repository/PostCommentRepository.php b/src/Repository/PostCommentRepository.php index b4b01877bc..c8645539b7 100644 --- a/src/Repository/PostCommentRepository.php +++ b/src/Repository/PostCommentRepository.php @@ -9,7 +9,6 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; -use App\Entity\DomainSubscription; use App\Entity\HashtagBlock; use App\Entity\HashtagLink; use App\Entity\HashtagSubscription; @@ -164,7 +163,7 @@ private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): void ->setParameter('tag', $criteria->tag); } - if($user && $criteria->subscribed) { + if ($user && $criteria->subscribed) { $qb->andWhere( 'c.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :follower) OR diff --git a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php index 6657f59ee0..ffb6537d0b 100644 --- a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php +++ b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php @@ -131,7 +131,8 @@ public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() self::assertCount(2, $result); } - public function testSubscribedHashtagIsIncludedInCombinedComments() { + public function testSubscribedHashtagIsIncludedInCombinedComments() + { $user = $this->getUserByUsername('John Doe'); $contentCreator = $this->getUserByUsername('poster'); $tag = $this->getHashtag('interesting'); From 83618f3675c3901bdcb4357b0e72fcb91312d853 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Fri, 31 Jul 2026 23:23:06 +0000 Subject: [PATCH 16/16] fix tests --- src/Repository/EntryCommentRepository.php | 6 +++--- src/Repository/EntryRepository.php | 6 +++--- src/Repository/PostCommentRepository.php | 6 +++--- src/Repository/PostRepository.php | 6 +++--- .../Functional/Controller/Api/User/UserRetrieveApiTest.php | 2 ++ 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Repository/EntryCommentRepository.php b/src/Repository/EntryCommentRepository.php index 41078caa46..0af884358f 100644 --- a/src/Repository/EntryCommentRepository.php +++ b/src/Repository/EntryCommentRepository.php @@ -193,7 +193,7 @@ private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): Quer OR ce.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :follower) OR - EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hl ON hs.hashtag = hl.hashtag WHERE hl.entryComment = c AND hs.user = :follower)' + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hsl ON hs.hashtag = hsl.hashtag WHERE hsl.entryComment = c AND hs.user = :follower)' ); $qb->setParameter('follower', $user); } @@ -227,8 +227,8 @@ private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): Quer $qb->andWhere( 'NOT EXISTS (' - .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hl ON hb.hashtag = hl.hashtag ' - .'WHERE hl.entryComment = c AND hb.user = :blocker' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hbl ON hb.hashtag = hbl.hashtag ' + .'WHERE hbl.entryComment = c AND hb.user = :blocker' .')' ); diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 4ea167ef69..68123d87d7 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -208,7 +208,7 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder OR e.user = :user OR - EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hl ON hs.hashtag = hl.hashtag WHERE hl.entry = e AND hs.user = :user)' + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hsl ON hs.hashtag = hsl.hashtag WHERE hsl.entry = e AND hs.user = :user)' ) ->setParameter('user', $this->security->getUser()); } @@ -244,8 +244,8 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder $qb->andWhere( 'NOT EXISTS (' - .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hl ON hb.hashtag = hl.hashtag ' - .'WHERE hl.entry = e AND hb.user = :blocker' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hbl ON hb.hashtag = hbl.hashtag ' + .'WHERE hbl.entry = e AND hb.user = :blocker' .')' ); diff --git a/src/Repository/PostCommentRepository.php b/src/Repository/PostCommentRepository.php index c8645539b7..c7725910aa 100644 --- a/src/Repository/PostCommentRepository.php +++ b/src/Repository/PostCommentRepository.php @@ -171,7 +171,7 @@ private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): void OR c.user = :follower OR - EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hl ON hs.hashtag = hl.hashtag WHERE hl.postComment = c AND hs.user = :follower)' + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hsl ON hs.hashtag = hsl.hashtag WHERE hsl.postComment = c AND hs.user = :follower)' ); $qb->setParameter('follower', $user); } @@ -183,8 +183,8 @@ private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): void $qb->andWhere( 'NOT EXISTS (' - .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hl ON hb.hashtag = hl.hashtag ' - .'WHERE hl.postComment = c AND hb.user = :blocker' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hbl ON hb.hashtag = hbl.hashtag ' + .'WHERE hbl.postComment = c AND hb.user = :blocker' .')' ); diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 822190807d..246e888986 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -181,7 +181,7 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder OR p.user = :user OR - EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hl ON hs.hashtag = hl.hashtag WHERE hl.post = p AND hs.user = :user)' + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hsl ON hs.hashtag = hsl.hashtag WHERE hsl.post = p AND hs.user = :user)' ); $qb->setParameter('user', $this->security->getUser()); } @@ -216,8 +216,8 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder $qb->andWhere( 'NOT EXISTS (' - .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hl ON hb.hashtag = hl.hashtag ' - .'WHERE hl.post = p AND hb.user = :blocker' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hbl ON hb.hashtag = hbl.hashtag ' + .'WHERE hbl.post = p AND hb.user = :blocker' .')' ); diff --git a/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php b/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php index 514b72eac0..895786c552 100644 --- a/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php +++ b/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php @@ -24,6 +24,8 @@ class UserRetrieveApiTest extends WebTestCase 'homepage', 'frontDefaultSort', 'commentDefaultSort', + 'showFollowingBoosts', + 'showCommentsOfSubscribedHashtags', 'featuredMagazines', 'preferredLanguages', 'customCss',