Compare commits

1 Commits
master ... main

Author SHA1 Message Date
SlimusMinus
818c8f6a11 Add Docker build and deploy pipeline
All checks were successful
deploy-backend / deploy (push) Successful in 8m24s
2026-10-07 22:56:56 +03:00
6 changed files with 462 additions and 7 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
.gradle
build
target
.idea

39
.env.example Normal file
View 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

View 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
View 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"]

View File

@@ -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}

View File

@@ -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);
}
}