Add Docker build and deploy pipeline
All checks were successful
deploy-backend / deploy (push) Successful in 8m24s
All checks were successful
deploy-backend / deploy (push) Successful in 8m24s
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.gradle
|
||||||
|
build
|
||||||
|
target
|
||||||
|
.idea
|
||||||
39
.env.example
Normal file
39
.env.example
Normal file
@@ -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
|
||||||
16
.gitea/workflows/deploy.yml
Normal file
16
.gitea/workflows/deploy.yml
Normal file
@@ -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
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
ffmpeg:
|
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:
|
spring:
|
||||||
servlet:
|
servlet:
|
||||||
@@ -8,9 +8,9 @@ spring:
|
|||||||
max-request-size: 20MB
|
max-request-size: 20MB
|
||||||
|
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://localhost:5432/nahodka
|
url: ${DB_URL:jdbc:postgresql://localhost:5432/nahodka}
|
||||||
username: user
|
username: ${DB_USER:user}
|
||||||
password: pass
|
password: ${DB_PASSWORD:pass}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
@@ -19,7 +19,7 @@ spring:
|
|||||||
# из-за чего базы расходились. Для разработки можно переопределить
|
# из-за чего базы расходились. Для разработки можно переопределить
|
||||||
# переменной окружения JPA_DDL_AUTO=update.
|
# переменной окружения JPA_DDL_AUTO=update.
|
||||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||||
show-sql: true
|
show-sql: ${JPA_SHOW_SQL:true}
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: true
|
format_sql: true
|
||||||
@@ -55,10 +55,10 @@ ai:
|
|||||||
multiplier: 2
|
multiplier: 2
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:mySuperSecretKeyForJwtTokenGenerationThatIsLongEnough2024!ReFound}
|
secret: ${JWT_SECRET}
|
||||||
access-expiration: ${JWT_ACCESS_EXPIRATION:900000} # 15 минут
|
access-expiration: ${JWT_ACCESS_EXPIRATION:900000} # 15 минут
|
||||||
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7 дней
|
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7 дней
|
||||||
|
|
||||||
app:
|
app:
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: http://localhost:5173,http://localhost:3000,http://192.168.1.76:3000
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000,http://192.168.1.76:3000}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user