@@ -0,0 +1,377 @@
package com.krylov.refound.service.admin ;
import static org.assertj.core.api.Assertions.assertThat ;
import static org.assertj.core.api.Assertions.assertThatThrownBy ;
import static org.mockito.ArgumentMatchers.any ;
import static org.mockito.ArgumentMatchers.anyCollection ;
import static org.mockito.ArgumentMatchers.anyList ;
import static org.mockito.ArgumentMatchers.anyString ;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS ;
import static org.mockito.Mockito.mock ;
import static org.mockito.Mockito.never ;
import static org.mockito.Mockito.verify ;
import static org.mockito.Mockito.when ;
import static org.mockito.Mockito.withSettings ;
import com.fasterxml.jackson.databind.ObjectMapper ;
import com.krylov.refound.dto.AddressInfo ;
import com.krylov.refound.dto.admin.AdminPostResponse ;
import com.krylov.refound.entity.Image ;
import com.krylov.refound.entity.Post ;
import com.krylov.refound.entity.User ;
import com.krylov.refound.enums.PostCategory ;
import com.krylov.refound.enums.PostStatus ;
import com.krylov.refound.enums.PostType ;
import com.krylov.refound.exception.ApiException ;
import com.krylov.refound.repository.ChatRepository ;
import com.krylov.refound.repository.FavoriteRepository ;
import com.krylov.refound.repository.ImageRepository ;
import com.krylov.refound.repository.PostRepository ;
import com.krylov.refound.service.FileStorageService ;
import com.krylov.refound.service.GeocodingService ;
import com.krylov.refound.service.StatisticsService ;
import com.krylov.refound.service.redis.MapCacheService ;
import com.krylov.refound.service.redis.PostCacheService ;
import jakarta.persistence.criteria.CriteriaBuilder ;
import jakarta.persistence.criteria.CriteriaQuery ;
import jakarta.persistence.criteria.Expression ;
import jakarta.persistence.criteria.Path ;
import jakarta.persistence.criteria.Predicate ;
import jakarta.persistence.criteria.Root ;
import java.util.Collection ;
import java.util.EnumSet ;
import java.util.List ;
import java.util.Set ;
import org.junit.jupiter.api.Test ;
import org.junit.jupiter.api.extension.ExtendWith ;
import org.mockito.ArgumentCaptor ;
import org.mockito.InjectMocks ;
import org.mockito.Mock ;
import org.mockito.Spy ;
import org.mockito.junit.jupiter.MockitoExtension ;
import org.springframework.data.domain.PageImpl ;
import org.springframework.data.domain.PageRequest ;
import org.springframework.data.domain.Pageable ;
import org.springframework.data.jpa.domain.Specification ;
import org.springframework.http.HttpStatus ;
@ExtendWith ( MockitoExtension . class )
class AdminPostServiceTest {
@Mock
private PostRepository postRepository ;
@Mock
private ImageRepository imageRepository ;
@Mock
private FavoriteRepository favoriteRepository ;
@Mock
private ChatRepository chatRepository ;
@Mock
private FileStorageService fileStorageService ;
@Mock
private GeocodingService geocodingService ;
@Mock
private PostCacheService postCacheService ;
@Mock
private MapCacheService mapCacheService ;
@Mock
private StatisticsService statisticsService ;
@Spy
private ObjectMapper objectMapper = new ObjectMapper ( ) ;
@InjectMocks
private AdminPostService service ;
private static Post post ( Long id , PostStatus status ) {
User owner = new User ( ) ;
owner . setId ( 7L ) ;
owner . setLogin ( " ivan " ) ;
owner . setName ( " Иван " ) ;
owner . setLastName ( " Петров " ) ;
Post post = new Post ( ) ;
post . setId ( id ) ;
post . setStatus ( status ) ;
post . setType ( PostType . LOSS ) ;
post . setTitle ( " Кот " ) ;
post . setCategory ( PostCategory . ANIMAL ) ;
post . setCity ( " Москва " ) ;
post . setUser ( owner ) ;
return post ;
}
@Test
void getPosts_fillsOwnerAndImages ( ) {
Post post = post ( 1L , PostStatus . ACTIVE ) ;
Image image = new Image ( ) ;
image . setId ( 5L ) ;
image . setUrl ( " images/cat.png " ) ;
image . setPost ( post ) ;
when ( postRepository . findAll ( any ( Specification . class ) , any ( Pageable . class ) ) )
. thenReturn ( new PageImpl < > ( List . of ( post ) ) ) ;
when ( imageRepository . findByPostIdIn ( anyCollection ( ) ) ) . thenReturn ( List . of ( image ) ) ;
List < AdminPostResponse > content = service
. getPosts ( null , null , null , PageRequest . of ( 0 , 20 ) )
. getContent ( ) ;
assertThat ( content ) . hasSize ( 1 ) ;
AdminPostResponse dto = content . get ( 0 ) ;
assertThat ( dto . getOwnerLogin ( ) ) . isEqualTo ( " ivan " ) ;
assertThat ( dto . getOwnerFirstName ( ) ) . isEqualTo ( " Иван " ) ;
assertThat ( dto . getOwnerLastName ( ) ) . isEqualTo ( " Петров " ) ;
assertThat ( dto . getImages ( ) ) . containsExactly ( " images/cat.png " ) ;
assertThat ( dto . getStatus ( ) ) . isEqualTo ( PostStatus . ACTIVE ) ;
}
@Test
void getPosts_rejectsUnknownFilterValue ( ) {
assertThatThrownBy ( ( ) - > service . getPosts ( null , " WRONG " , null , PageRequest . of ( 0 , 20 ) ) )
. isInstanceOf ( ApiException . class )
. satisfies ( ex - > assertThat ( ( ( ApiException ) ex ) . getStatus ( ) ) . isEqualTo ( HttpStatus . BAD_REQUEST ) ) ;
}
@Test
void getPosts_clampsPageSize ( ) {
when ( postRepository . findAll ( any ( Specification . class ) , any ( Pageable . class ) ) )
. thenReturn ( new PageImpl < > ( List . of ( ) ) ) ;
service . getPosts ( null , null , null , PageRequest . of ( 0 , 5000 ) ) ;
ArgumentCaptor < Pageable > captor = ArgumentCaptor . forClass ( Pageable . class ) ;
verify ( postRepository ) . findAll ( any ( Specification . class ) , captor . capture ( ) ) ;
assertThat ( captor . getValue ( ) . getPageSize ( ) ) . isEqualTo ( 100 ) ;
assertThat ( captor . getValue ( ) . getSort ( ) . getOrderFor ( " createdAt " ) ) . isNotNull ( ) ;
}
@Test
void getPosts_limitsStatusesToActiveAndClosed ( ) {
assertThat ( statusesUsedByGetPosts ( ) ) . containsExactlyInAnyOrder ( PostStatus . ACTIVE , PostStatus . CLOSED ) ;
}
@Test
void getRejectedPosts_limitsStatusesToRejected ( ) {
assertThat ( statusesUsedByGetRejectedPosts ( ) ) . containsExactly ( PostStatus . REJECTED ) ;
}
@Test
void feedsRejectStatusFromAnotherFeed ( ) {
assertThatThrownBy ( ( ) - > service . getPosts ( null , null , " REJECTED " , PageRequest . of ( 0 , 20 ) ) )
. isInstanceOf ( ApiException . class )
. satisfies ( ex - > assertThat ( ( ( ApiException ) ex ) . getStatus ( ) ) . isEqualTo ( HttpStatus . BAD_REQUEST ) ) ;
assertThatThrownBy ( ( ) - > service . getRejectedPosts ( null , null , " ACTIVE " , PageRequest . of ( 0 , 20 ) ) )
. isInstanceOf ( ApiException . class )
. satisfies ( ex - > assertThat ( ( ( ApiException ) ex ) . getStatus ( ) ) . isEqualTo ( HttpStatus . BAD_REQUEST ) ) ;
verify ( postRepository , never ( ) ) . findAll ( any ( Specification . class ) , any ( Pageable . class ) ) ;
}
@Test
void getRejectedPosts_returnsRejectedPostsWithOwnerAndImages ( ) {
Post post = post ( 1L , PostStatus . REJECTED ) ;
Image image = new Image ( ) ;
image . setUrl ( " images/cat.png " ) ;
image . setPost ( post ) ;
when ( postRepository . findAll ( any ( Specification . class ) , any ( Pageable . class ) ) )
. thenReturn ( new PageImpl < > ( List . of ( post ) ) ) ;
when ( imageRepository . findByPostIdIn ( anyCollection ( ) ) ) . thenReturn ( List . of ( image ) ) ;
List < AdminPostResponse > content = service
. getRejectedPosts ( null , null , null , PageRequest . of ( 0 , 20 ) )
. getContent ( ) ;
assertThat ( content ) . hasSize ( 1 ) ;
assertThat ( content . get ( 0 ) . getStatus ( ) ) . isEqualTo ( PostStatus . REJECTED ) ;
assertThat ( content . get ( 0 ) . getOwnerLogin ( ) ) . isEqualTo ( " ivan " ) ;
assertThat ( content . get ( 0 ) . getImages ( ) ) . containsExactly ( " images/cat.png " ) ;
}
/**
* Статусы достаются из спецификации, которую сервис отдал в репозиторий: сам SQL
* без БД не проверить, а IN(...) собирается ровно из переданного allow-list.
*/
private Set < PostStatus > statusesUsedByGetPosts ( ) {
when ( postRepository . findAll ( any ( Specification . class ) , any ( Pageable . class ) ) )
. thenReturn ( new PageImpl < > ( List . of ( ) ) ) ;
service . getPosts ( null , null , null , PageRequest . of ( 0 , 20 ) ) ;
return capturedStatuses ( ) ;
}
private Set < PostStatus > statusesUsedByGetRejectedPosts ( ) {
when ( postRepository . findAll ( any ( Specification . class ) , any ( Pageable . class ) ) )
. thenReturn ( new PageImpl < > ( List . of ( ) ) ) ;
service . getRejectedPosts ( null , null , null , PageRequest . of ( 0 , 20 ) ) ;
return capturedStatuses ( ) ;
}
@SuppressWarnings ( " unchecked " )
private Set < PostStatus > capturedStatuses ( ) {
ArgumentCaptor < Specification < Post > > specCaptor = ArgumentCaptor . forClass ( Specification . class ) ;
verify ( postRepository ) . findAll ( specCaptor . capture ( ) , any ( Pageable . class ) ) ;
Set < PostStatus > statuses = EnumSet . noneOf ( PostStatus . class ) ;
// Hibernate отдаёт из Path.in() объект, который ещё и Predicate, — в тесте так же.
Path < Object > path = ( Path < Object > ) mock ( Path . class , withSettings ( ) . extraInterfaces ( Predicate . class ) ) ;
Expression < Integer > inExpression = ( Expression < Integer > ) ( Expression < ? > ) path ;
Root < Post > root = mock ( Root . class ) ;
// deep stubs: Specification.allOf комбинирует предикаты через CriteriaBuilder.and()
CriteriaBuilder cb = mock ( CriteriaBuilder . class , RETURNS_DEEP_STUBS ) ;
when ( root . < Object > get ( anyString ( ) ) ) . thenReturn ( path ) ;
when ( path . in ( any ( Collection . class ) ) ) . thenAnswer ( invocation - > {
statuses . addAll ( ( Collection < PostStatus > ) invocation . getArgument ( 0 ) ) ;
return inExpression ;
} ) ;
specCaptor . getValue ( ) . toPredicate ( root , mock ( CriteriaQuery . class ) , cb ) ;
return statuses ;
}
@Test
void updatePost_keepsStatusAndInvalidatesCache ( ) {
Post post = post ( 1L , PostStatus . ACTIVE ) ;
when ( postRepository . findById ( 1L ) ) . thenReturn ( java . util . Optional . of ( post ) ) ;
when ( postRepository . save ( any ( Post . class ) ) ) . thenAnswer ( inv - > inv . getArgument ( 0 ) ) ;
AdminPostResponse dto = service . updatePost ( 1L , " FOUND " , " Найден кот " , " Москва " ,
null , null , " описание " , null , " +7 " , true , " 100 " , List . of ( ) , " [] " ) ;
assertThat ( dto . getStatus ( ) ) . isEqualTo ( PostStatus . ACTIVE ) ;
assertThat ( post . getStatus ( ) ) . isEqualTo ( PostStatus . ACTIVE ) ;
assertThat ( post . getType ( ) ) . isEqualTo ( PostType . FOUND ) ;
assertThat ( post . getCity ( ) ) . isEqualTo ( " Москва " ) ;
assertThat ( post . getIsReward ( ) ) . isTrue ( ) ;
verify ( postCacheService ) . invalidatePosts ( ) ;
verify ( mapCacheService ) . clearMapMarkersCache ( ) ;
}
@Test
void updatePost_dropsImagesMissingFromExistingList ( ) {
Post post = post ( 1L , PostStatus . ACTIVE ) ;
Image kept = new Image ( ) ;
kept . setUrl ( " images/keep.png " ) ;
kept . setPost ( post ) ;
Image dropped = new Image ( ) ;
dropped . setUrl ( " images/drop.png " ) ;
dropped . setPost ( post ) ;
post . getImages ( ) . addAll ( List . of ( kept , dropped ) ) ;
when ( postRepository . findById ( 1L ) ) . thenReturn ( java . util . Optional . of ( post ) ) ;
when ( postRepository . save ( any ( Post . class ) ) ) . thenAnswer ( inv - > inv . getArgument ( 0 ) ) ;
service . updatePost ( 1L , " LOSS " , " Кот " , " Москва " , null , null , null , null , null ,
false , null , List . of ( ) , " [ \" images/keep.png \" ] " ) ;
assertThat ( post . getImages ( ) ) . extracting ( Image : : getUrl ) . containsExactly ( " images/keep.png " ) ;
verify ( fileStorageService ) . deleteFile ( " images/drop.png " ) ;
verify ( fileStorageService , never ( ) ) . deleteFile ( " images/keep.png " ) ;
}
@Test
void updatePost_fallsBackToAddressWhenGeocodingUnavailable ( ) {
Post post = post ( 1L , PostStatus . ACTIVE ) ;
when ( postRepository . findById ( 1L ) ) . thenReturn ( java . util . Optional . of ( post ) ) ;
when ( postRepository . save ( any ( Post . class ) ) ) . thenAnswer ( inv - > inv . getArgument ( 0 ) ) ;
when ( geocodingService . getAddress ( " 55.0 " , " 37.0 " ) )
. thenThrow ( new ApiException ( com . krylov . refound . enums . ErrorCode . INTERNAL_ERROR ,
" Сервис геокодирования недоступен. " , HttpStatus . SERVICE_UNAVAILABLE ) ) ;
service . updatePost ( 1L , " LOSS " , " Кот " , " Тверь " , 55 . 0 , 37 . 0 , null , null , null ,
false , null , List . of ( ) , " [] " ) ;
assertThat ( post . getCity ( ) ) . isEqualTo ( " Тверь " ) ;
}
@Test
void updateStatus_closesPostAndIncrementsStatistics ( ) {
Post post = post ( 1L , PostStatus . ACTIVE ) ;
when ( postRepository . findById ( 1L ) ) . thenReturn ( java . util . Optional . of ( post ) ) ;
when ( postRepository . save ( any ( Post . class ) ) ) . thenAnswer ( inv - > inv . getArgument ( 0 ) ) ;
AdminPostResponse dto = service . updateStatus ( 1L , " CLOSED " ) ;
assertThat ( dto . getStatus ( ) ) . isEqualTo ( PostStatus . CLOSED ) ;
verify ( statisticsService ) . incrementFound ( ) ;
verify ( postCacheService ) . invalidatePosts ( ) ;
}
@Test
void updateStatus_reopeningDoesNotTouchStatistics ( ) {
Post post = post ( 1L , PostStatus . CLOSED ) ;
when ( postRepository . findById ( 1L ) ) . thenReturn ( java . util . Optional . of ( post ) ) ;
when ( postRepository . save ( any ( Post . class ) ) ) . thenAnswer ( inv - > inv . getArgument ( 0 ) ) ;
AdminPostResponse dto = service . updateStatus ( 1L , " active " ) ;
assertThat ( dto . getStatus ( ) ) . isEqualTo ( PostStatus . ACTIVE ) ;
verify ( statisticsService , never ( ) ) . incrementFound ( ) ;
}
@Test
void updateStatus_sameStatusIsNoop ( ) {
Post post = post ( 1L , PostStatus . ACTIVE ) ;
when ( postRepository . findById ( 1L ) ) . thenReturn ( java . util . Optional . of ( post ) ) ;
AdminPostResponse dto = service . updateStatus ( 1L , " ACTIVE " ) ;
assertThat ( dto . getStatus ( ) ) . isEqualTo ( PostStatus . ACTIVE ) ;
verify ( postRepository , never ( ) ) . save ( any ( Post . class ) ) ;
verify ( statisticsService , never ( ) ) . incrementFound ( ) ;
verify ( postCacheService , never ( ) ) . invalidatePosts ( ) ;
}
@Test
void updateStatus_rejectsGarbage ( ) {
assertThatThrownBy ( ( ) - > service . updateStatus ( 1L , " banana " ) )
. isInstanceOf ( ApiException . class )
. satisfies ( ex - > assertThat ( ( ( ApiException ) ex ) . getStatus ( ) ) . isEqualTo ( HttpStatus . BAD_REQUEST ) ) ;
}
@Test
void updatePost_throwsNotFoundForMissingPost ( ) {
when ( postRepository . findById ( 99L ) ) . thenReturn ( java . util . Optional . empty ( ) ) ;
assertThatThrownBy ( ( ) - > service . updatePost ( 99L , " LOSS " , " t " , null , null , null , null , null ,
null , false , null , List . of ( ) , " [] " ) )
. isInstanceOf ( ApiException . class )
. satisfies ( ex - > assertThat ( ( ( ApiException ) ex ) . getStatus ( ) ) . isEqualTo ( HttpStatus . NOT_FOUND ) ) ;
}
@Test
void deletePost_clearsFilesFavoritesAndChatLinks ( ) {
Post post = post ( 1L , PostStatus . CLOSED ) ;
Image image = new Image ( ) ;
image . setUrl ( " images/cat.png " ) ;
image . setPost ( post ) ;
post . getImages ( ) . add ( image ) ;
when ( postRepository . findById ( 1L ) ) . thenReturn ( java . util . Optional . of ( post ) ) ;
service . deletePost ( 1L ) ;
verify ( fileStorageService ) . deleteFile ( " images/cat.png " ) ;
verify ( favoriteRepository ) . deleteByPostIn ( anyList ( ) ) ;
verify ( chatRepository ) . clearPostReference ( 1L ) ;
verify ( postRepository ) . delete ( post ) ;
verify ( postCacheService ) . invalidatePosts ( ) ;
}
@Test
void deletePost_doesNotFailWhenFileStorageIsDown ( ) {
Post post = post ( 1L , PostStatus . CLOSED ) ;
Image image = new Image ( ) ;
image . setUrl ( " images/cat.png " ) ;
image . setPost ( post ) ;
post . getImages ( ) . add ( image ) ;
when ( postRepository . findById ( 1L ) ) . thenReturn ( java . util . Optional . of ( post ) ) ;
org . mockito . Mockito . doThrow ( new RuntimeException ( " MinIO недоступен " ) )
. when ( fileStorageService ) . deleteFile ( any ( ) ) ;
service . deletePost ( 1L ) ;
verify ( postRepository ) . delete ( post ) ;
}
}