diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3eea6a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.gradle +build +target +.idea \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec1aaed --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# Шаблон переменных окружения. Скопируй в .env и заполни значениями. +# Сам .env добавлен в .gitignore и не должен попадать в репозиторий. +# +# Как приложение их получает: +# docker compose читает .env автоматически; +# Spring Boot — нет. Нужно экспортировать переменные в терминал +# или прописать их в Environment variables в настройках запуска IDE. + +# --- Обязательные: без них приложение не стартует --- + +# Ключи доступа к MinIO (S3-совместимое хранилище). +# ВНИМАНИЕ: они должны совпадать с MINIO_ACCESS_KEY / MINIO_SECRET_KEY +# в docker-compose.yaml, иначе приложение не подключится к MinIO. +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= + +# Пароль Redis. Тот же, что в docker-compose.yaml. +# docker-compose падает с понятной ошибкой, если переменная пустая. +REDIS_PASSWORD= + +# --- Рекомендуется задать --- + +# Секрет подписи JWT. Без переменной берётся захардкоженное значение +# из application.yaml — подделать такой токен может кто угодно. +# Сгенерировать, например, так: +# openssl rand -base64 48 +JWT_SECRET= + +# --- Необязательно: есть значения по умолчанию --- + +# REDIS_HOST=localhost +# MINIO_ENDPOINT=http://localhost:9010 +# MINIO_BUCKET=refound-images +# MINIO_ADS_BUCKET=ads-media +# MINIO_REGION=us-east-1 +# AI_SERVICE_URL=http://localhost:8000 +# JWT_ACCESS_EXPIRATION=900000 +# JWT_REFRESH_EXPIRATION=604800000 +# JPA_DDL_AUTO=validate diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..8b8b55e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,16 @@ +name: deploy-backend +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: host + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build -t nakhodka-backend:latest -t nakhodka-backend:${{ github.sha }} . + - name: Restart backend + run: docker compose -f /opt/nakhodka/docker-compose.yml up -d backend + - name: Cleanup + run: docker image prune -f \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a78a08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM eclipse-temurin:25-jdk AS build +WORKDIR /app +COPY . . +RUN sed -i 's/\r$//' gradlew \ + && chmod +x gradlew \ + && ./gradlew bootJar --no-daemon -x test \ + && cp "$(ls build/libs/*.jar | grep -v plain | head -n1)" /app/app.jar + +FROM eclipse-temurin:25-jre +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -r -u 1001 app +WORKDIR /app +COPY --from=build /app/app.jar app.jar +USER app +EXPOSE 8080 +ENTRYPOINT ["java", "-Xmx768m", "-jar", "app.jar"] \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a3ce269..e94f03d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,5 +1,5 @@ ffmpeg: - binary-path: C:\ffmpeg\ffmpeg-9.0.2-essentials_build\bin\ffmpeg.exe + binary-path: ${FFMPEG_PATH:C:\ffmpeg\ffmpeg-9.0.2-essentials_build\bin\ffmpeg.exe} spring: servlet: @@ -8,9 +8,9 @@ spring: max-request-size: 20MB datasource: - url: jdbc:postgresql://localhost:5432/nahodka - username: user - password: pass + url: ${DB_URL:jdbc:postgresql://localhost:5432/nahodka} + username: ${DB_USER:user} + password: ${DB_PASSWORD:pass} driver-class-name: org.postgresql.Driver jpa: @@ -19,7 +19,7 @@ spring: # из-за чего базы расходились. Для разработки можно переопределить # переменной окружения JPA_DDL_AUTO=update. ddl-auto: ${JPA_DDL_AUTO:validate} - show-sql: true + show-sql: ${JPA_SHOW_SQL:true} properties: hibernate: format_sql: true @@ -55,10 +55,10 @@ ai: multiplier: 2 jwt: - secret: ${JWT_SECRET:mySuperSecretKeyForJwtTokenGenerationThatIsLongEnough2024!ReFound} + secret: ${JWT_SECRET} access-expiration: ${JWT_ACCESS_EXPIRATION:900000} # 15 минут refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7 дней app: cors: - allowed-origins: http://localhost:5173,http://localhost:3000,http://192.168.1.76:3000 \ No newline at end of file + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000,http://192.168.1.76:3000} \ No newline at end of file diff --git a/src/test/java/com/krylov/refound/service/admin/AdminPostServiceTest.java b/src/test/java/com/krylov/refound/service/admin/AdminPostServiceTest.java new file mode 100644 index 0000000..ff88ad7 --- /dev/null +++ b/src/test/java/com/krylov/refound/service/admin/AdminPostServiceTest.java @@ -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 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 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 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 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 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 capturedStatuses() { + ArgumentCaptor> specCaptor = ArgumentCaptor.forClass(Specification.class); + verify(postRepository).findAll(specCaptor.capture(), any(Pageable.class)); + + Set statuses = EnumSet.noneOf(PostStatus.class); + // Hibernate отдаёт из Path.in() объект, который ещё и Predicate, — в тесте так же. + Path path = (Path) mock(Path.class, withSettings().extraInterfaces(Predicate.class)); + Expression inExpression = (Expression) (Expression) path; + Root root = mock(Root.class); + // deep stubs: Specification.allOf комбинирует предикаты через CriteriaBuilder.and() + CriteriaBuilder cb = mock(CriteriaBuilder.class, RETURNS_DEEP_STUBS); + + when(root.get(anyString())).thenReturn(path); + when(path.in(any(Collection.class))).thenAnswer(invocation -> { + statuses.addAll((Collection) 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); + } +} \ No newline at end of file