diff --git a/build.gradle.kts b/build.gradle.kts index 22aab14..34840c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,9 @@ dependencies { annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") + // MinIO S3-compatible object storage + implementation("io.minio:minio:8.5.7") + // Tracing (Zipkin) implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") implementation("org.springframework.boot:spring-boot-starter-zipkin") @@ -58,6 +61,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.testcontainers:junit-jupiter:1.20.4") testImplementation("org.testcontainers:postgresql:1.20.4") + testImplementation("org.testcontainers:minio:1.20.4") testImplementation("io.rest-assured:rest-assured:5.5.0") testCompileOnly("org.projectlombok:lombok") diff --git a/environment/.local.env b/environment/.local.env index 9b21147..09a8f4c 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -6,3 +6,9 @@ POSTGRES_HOST=devoops-postgres POSGTES_PORT=5432 DB_USERNAME=accommodation-service DB_PASSWORD=accommodation-service-pass + +# MinIO configuration +MINIO_ENDPOINT=http://devoops-minio:9000 +MINIO_ACCESS_KEY=devoops +MINIO_SECRET_KEY=devoops123 +MINIO_BUCKET=accommodation-photos diff --git a/src/main/java/com/devoops/accommodation/config/MinioConfig.java b/src/main/java/com/devoops/accommodation/config/MinioConfig.java new file mode 100644 index 0000000..b539886 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/config/MinioConfig.java @@ -0,0 +1,27 @@ +package com.devoops.accommodation.config; + +import io.minio.MinioClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MinioConfig { + + @Value("${minio.endpoint}") + private String endpoint; + + @Value("${minio.access-key}") + private String accessKey; + + @Value("${minio.secret-key}") + private String secretKey; + + @Bean + public MinioClient minioClient() { + return MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + } +} diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java new file mode 100644 index 0000000..6902123 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java @@ -0,0 +1,72 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RequireRole; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.AccommodationPhoto; +import com.devoops.accommodation.service.AccommodationPhotoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.InputStream; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/accommodation/{accommodationId}/photos") +@RequiredArgsConstructor +public class AccommodationPhotoController { + + private final AccommodationPhotoService photoService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RequireRole("HOST") + public ResponseEntity uploadPhoto( + @PathVariable UUID accommodationId, + @RequestPart("file") MultipartFile file, + @RequestParam(value = "displayOrder", required = false) Integer displayOrder, + UserContext userContext) { + AccommodationPhotoResponse response = photoService.uploadPhoto(accommodationId, file, displayOrder, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public ResponseEntity> listPhotos(@PathVariable UUID accommodationId) { + List photos = photoService.listPhotos(accommodationId); + return ResponseEntity.ok(photos); + } + + @GetMapping("/{photoId}") + public ResponseEntity getPhoto( + @PathVariable UUID accommodationId, + @PathVariable UUID photoId) { + AccommodationPhoto metadata = photoService.getPhotoMetadata(accommodationId, photoId); + InputStream inputStream = photoService.getPhotoFile(accommodationId, photoId); + + StreamingResponseBody responseBody = outputStream -> { + try (inputStream) { + inputStream.transferTo(outputStream); + } + }; + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(metadata.getContentType())) + .contentLength(metadata.getFileSize()) + .body(responseBody); + } + + @DeleteMapping("/{photoId}") + @RequireRole("HOST") + public ResponseEntity deletePhoto( + @PathVariable UUID accommodationId, + @PathVariable UUID photoId, + UserContext userContext) { + photoService.deletePhoto(accommodationId, photoId, userContext); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/devoops/accommodation/dto/response/AccommodationPhotoResponse.java b/src/main/java/com/devoops/accommodation/dto/response/AccommodationPhotoResponse.java new file mode 100644 index 0000000..736458f --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/response/AccommodationPhotoResponse.java @@ -0,0 +1,15 @@ +package com.devoops.accommodation.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record AccommodationPhotoResponse( + UUID id, + UUID accommodationId, + String originalFilename, + String contentType, + Long fileSize, + Integer displayOrder, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} diff --git a/src/main/java/com/devoops/accommodation/entity/AccommodationPhoto.java b/src/main/java/com/devoops/accommodation/entity/AccommodationPhoto.java new file mode 100644 index 0000000..5e6ebd4 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/AccommodationPhoto.java @@ -0,0 +1,38 @@ +package com.devoops.accommodation.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.SQLRestriction; + +import java.util.UUID; + +@Entity +@Table(name = "accommodation_photos") +@SQLRestriction("is_deleted = false") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public class AccommodationPhoto extends BaseEntity { + + @Column(name = "accommodation_id", nullable = false) + private UUID accommodationId; + + @Column(name = "storage_filename", nullable = false, unique = true) + private String storageFilename; + + @Column(name = "original_filename", nullable = false) + private String originalFilename; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; +} diff --git a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java index 100e06d..2f48f7e 100644 --- a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import java.util.Map; import java.util.stream.Collectors; @@ -45,4 +46,29 @@ public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); } + + @ExceptionHandler(PhotoNotFoundException.class) + public ProblemDetail handlePhotoNotFound(PhotoNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(PhotoLimitExceededException.class) + public ProblemDetail handlePhotoLimitExceeded(PhotoLimitExceededException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(PhotoStorageException.class) + public ProblemDetail handlePhotoStorage(PhotoStorageException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + } + + @ExceptionHandler(InvalidContentTypeException.class) + public ProblemDetail handleInvalidContentType(InvalidContentTypeException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ProblemDetail handleMaxUploadSize(MaxUploadSizeExceededException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "File size exceeds the maximum allowed limit"); + } } diff --git a/src/main/java/com/devoops/accommodation/exception/InvalidContentTypeException.java b/src/main/java/com/devoops/accommodation/exception/InvalidContentTypeException.java new file mode 100644 index 0000000..835fa40 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/InvalidContentTypeException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class InvalidContentTypeException extends RuntimeException { + public InvalidContentTypeException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/PhotoLimitExceededException.java b/src/main/java/com/devoops/accommodation/exception/PhotoLimitExceededException.java new file mode 100644 index 0000000..bf2bd05 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/PhotoLimitExceededException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class PhotoLimitExceededException extends RuntimeException { + public PhotoLimitExceededException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/PhotoNotFoundException.java b/src/main/java/com/devoops/accommodation/exception/PhotoNotFoundException.java new file mode 100644 index 0000000..0323102 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/PhotoNotFoundException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class PhotoNotFoundException extends RuntimeException { + public PhotoNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/PhotoStorageException.java b/src/main/java/com/devoops/accommodation/exception/PhotoStorageException.java new file mode 100644 index 0000000..b15c669 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/PhotoStorageException.java @@ -0,0 +1,11 @@ +package com.devoops.accommodation.exception; + +public class PhotoStorageException extends RuntimeException { + public PhotoStorageException(String message) { + super(message); + } + + public PhotoStorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/devoops/accommodation/mapper/AccommodationPhotoMapper.java b/src/main/java/com/devoops/accommodation/mapper/AccommodationPhotoMapper.java new file mode 100644 index 0000000..71c571e --- /dev/null +++ b/src/main/java/com/devoops/accommodation/mapper/AccommodationPhotoMapper.java @@ -0,0 +1,15 @@ +package com.devoops.accommodation.mapper; + +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.AccommodationPhoto; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface AccommodationPhotoMapper { + + AccommodationPhotoResponse toResponse(AccommodationPhoto photo); + + List toResponseList(List photos); +} diff --git a/src/main/java/com/devoops/accommodation/repository/AccommodationPhotoRepository.java b/src/main/java/com/devoops/accommodation/repository/AccommodationPhotoRepository.java new file mode 100644 index 0000000..d37315b --- /dev/null +++ b/src/main/java/com/devoops/accommodation/repository/AccommodationPhotoRepository.java @@ -0,0 +1,22 @@ +package com.devoops.accommodation.repository; + +import com.devoops.accommodation.entity.AccommodationPhoto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AccommodationPhotoRepository extends JpaRepository { + + List findByAccommodationIdOrderByDisplayOrderAsc(UUID accommodationId); + + Optional findByIdAndAccommodationId(UUID id, UUID accommodationId); + + long countByAccommodationId(UUID accommodationId); + + @Query("SELECT COALESCE(MAX(p.displayOrder), -1) FROM AccommodationPhoto p WHERE p.accommodationId = :accommodationId") + Integer findMaxDisplayOrder(@Param("accommodationId") UUID accommodationId); +} diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java b/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java new file mode 100644 index 0000000..655ae5b --- /dev/null +++ b/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java @@ -0,0 +1,132 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AccommodationPhoto; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.mapper.AccommodationPhotoMapper; +import com.devoops.accommodation.repository.AccommodationPhotoRepository; +import com.devoops.accommodation.repository.AccommodationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AccommodationPhotoService { + + private final AccommodationPhotoRepository photoRepository; + private final AccommodationRepository accommodationRepository; + private final PhotoStorageService photoStorageService; + private final AccommodationPhotoMapper photoMapper; + + @Value("${app.photo.max-photos-per-accommodation}") + private int maxPhotosPerAccommodation; + + @Value("${app.photo.allowed-content-types}") + private String allowedContentTypesConfig; + + @Transactional + public AccommodationPhotoResponse uploadPhoto(UUID accommodationId, MultipartFile file, Integer displayOrder, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + validateContentType(file.getContentType()); + validatePhotoLimit(accommodationId); + + String storageFilename = photoStorageService.store(file); + + int order = displayOrder != null ? displayOrder : photoRepository.findMaxDisplayOrder(accommodationId) + 1; + + AccommodationPhoto photo = AccommodationPhoto.builder() + .accommodationId(accommodationId) + .storageFilename(storageFilename) + .originalFilename(file.getOriginalFilename()) + .contentType(file.getContentType()) + .fileSize(file.getSize()) + .displayOrder(order) + .build(); + + photo = photoRepository.saveAndFlush(photo); + log.info("Uploaded photo {} for accommodation {}", photo.getId(), accommodationId); + + return photoMapper.toResponse(photo); + } + + @Transactional(readOnly = true) + public List listPhotos(UUID accommodationId) { + findAccommodationOrThrow(accommodationId); + List photos = photoRepository.findByAccommodationIdOrderByDisplayOrderAsc(accommodationId); + return photoMapper.toResponseList(photos); + } + + @Transactional(readOnly = true) + public InputStream getPhotoFile(UUID accommodationId, UUID photoId) { + AccommodationPhoto photo = findPhotoOrThrow(accommodationId, photoId); + return photoStorageService.loadAsStream(photo.getStorageFilename()); + } + + @Transactional(readOnly = true) + public AccommodationPhoto getPhotoMetadata(UUID accommodationId, UUID photoId) { + return findPhotoOrThrow(accommodationId, photoId); + } + + @Transactional + public void deletePhoto(UUID accommodationId, UUID photoId, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + + AccommodationPhoto photo = findPhotoOrThrow(accommodationId, photoId); + + photoStorageService.delete(photo.getStorageFilename()); + + photo.setDeleted(true); + photoRepository.save(photo); + + log.info("Deleted photo {} from accommodation {}", photoId, accommodationId); + } + + private Accommodation findAccommodationOrThrow(UUID accommodationId) { + return accommodationRepository.findById(accommodationId) + .orElseThrow(() -> new AccommodationNotFoundException("Accommodation not found with id: " + accommodationId)); + } + + private AccommodationPhoto findPhotoOrThrow(UUID accommodationId, UUID photoId) { + return photoRepository.findByIdAndAccommodationId(photoId, accommodationId) + .orElseThrow(() -> new PhotoNotFoundException("Photo not found with id: " + photoId)); + } + + private void validateOwnership(Accommodation accommodation, UserContext userContext) { + if (!accommodation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You are not the owner of this accommodation"); + } + } + + private void validateContentType(String contentType) { + Set allowedTypes = Arrays.stream(allowedContentTypesConfig.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + + if (contentType == null || !allowedTypes.contains(contentType)) { + throw new InvalidContentTypeException("Invalid content type: " + contentType + ". Allowed types: " + allowedContentTypesConfig); + } + } + + private void validatePhotoLimit(UUID accommodationId) { + long currentCount = photoRepository.countByAccommodationId(accommodationId); + if (currentCount >= maxPhotosPerAccommodation) { + throw new PhotoLimitExceededException("Maximum number of photos (" + maxPhotosPerAccommodation + ") reached for this accommodation"); + } + } +} diff --git a/src/main/java/com/devoops/accommodation/service/PhotoStorageService.java b/src/main/java/com/devoops/accommodation/service/PhotoStorageService.java new file mode 100644 index 0000000..42c226d --- /dev/null +++ b/src/main/java/com/devoops/accommodation/service/PhotoStorageService.java @@ -0,0 +1,106 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.exception.PhotoStorageException; +import io.minio.*; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PhotoStorageService { + + private final MinioClient minioClient; + + @Value("${minio.bucket}") + private String bucketName; + + @PostConstruct + public void init() { + try { + boolean bucketExists = minioClient.bucketExists( + BucketExistsArgs.builder() + .bucket(bucketName) + .build() + ); + + if (!bucketExists) { + minioClient.makeBucket( + MakeBucketArgs.builder() + .bucket(bucketName) + .build() + ); + log.info("Created MinIO bucket: {}", bucketName); + } else { + log.info("MinIO bucket already exists: {}", bucketName); + } + } catch (Exception e) { + log.error("Failed to initialize MinIO bucket: {}", bucketName, e); + throw new PhotoStorageException("Failed to initialize photo storage", e); + } + } + + public String store(MultipartFile file) { + String objectKey = generateObjectKey(file.getOriginalFilename()); + + try (InputStream inputStream = file.getInputStream()) { + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .stream(inputStream, file.getSize(), -1) + .contentType(file.getContentType()) + .build() + ); + log.debug("Stored file {} as {} in bucket {}", file.getOriginalFilename(), objectKey, bucketName); + return objectKey; + } catch (Exception e) { + log.error("Failed to store file: {}", file.getOriginalFilename(), e); + throw new PhotoStorageException("Failed to store photo", e); + } + } + + public InputStream loadAsStream(String objectKey) { + try { + return minioClient.getObject( + GetObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .build() + ); + } catch (Exception e) { + log.error("Failed to load file: {}", objectKey, e); + throw new PhotoStorageException("Failed to load photo", e); + } + } + + public void delete(String objectKey) { + try { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .build() + ); + log.debug("Deleted file {} from bucket {}", objectKey, bucketName); + } catch (Exception e) { + log.error("Failed to delete file: {}", objectKey, e); + throw new PhotoStorageException("Failed to delete photo", e); + } + } + + private String generateObjectKey(String originalFilename) { + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + return UUID.randomUUID() + extension; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5024948..f7053cc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -29,3 +29,18 @@ management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_ management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.health.show-details=always management.prometheus.metrics.export.enabled=true + +# Multipart upload configuration +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=50MB + +# MinIO configuration +minio.endpoint=${MINIO_ENDPOINT:http://devoops-minio:9000} +minio.access-key=${MINIO_ACCESS_KEY:devoops} +minio.secret-key=${MINIO_SECRET_KEY:devoops123} +minio.bucket=${MINIO_BUCKET:accommodation-photos} + +# Photo configuration +app.photo.max-photos-per-accommodation=20 +app.photo.allowed-content-types=image/jpeg,image/png,image/webp diff --git a/src/main/resources/db/migration/V4__create_accommodation_photos_table.sql b/src/main/resources/db/migration/V4__create_accommodation_photos_table.sql new file mode 100644 index 0000000..53d31c0 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_accommodation_photos_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE accommodation_photos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + accommodation_id UUID NOT NULL REFERENCES accommodations(id) ON DELETE CASCADE, + storage_filename VARCHAR(255) NOT NULL UNIQUE, + original_filename VARCHAR(255) NOT NULL, + content_type VARCHAR(50) NOT NULL, + file_size BIGINT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_accommodation_photos_accommodation_id ON accommodation_photos(accommodation_id); +CREATE INDEX idx_accommodation_photos_display_order ON accommodation_photos(accommodation_id, display_order); + +ALTER TABLE accommodation_photos ADD CONSTRAINT chk_content_type + CHECK (content_type IN ('image/jpeg', 'image/png', 'image/webp')); diff --git a/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java new file mode 100644 index 0000000..7829dd3 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java @@ -0,0 +1,304 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RoleAuthorizationInterceptor; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.config.UserContextResolver; +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.AccommodationPhoto; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.service.AccommodationPhotoService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.io.ByteArrayInputStream; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class AccommodationPhotoControllerTest { + + private MockMvc mockMvc; + + @Mock + private AccommodationPhotoService photoService; + + @InjectMocks + private AccommodationPhotoController photoController; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID PHOTO_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(photoController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private AccommodationPhotoResponse createPhotoResponse() { + return new AccommodationPhotoResponse( + PHOTO_ID, ACCOMMODATION_ID, "test.jpg", "image/jpeg", + 1024L, 0, LocalDateTime.now(), LocalDateTime.now() + ); + } + + private AccommodationPhoto createPhotoEntity() { + return AccommodationPhoto.builder() + .id(PHOTO_ID) + .accommodationId(ACCOMMODATION_ID) + .storageFilename("uuid.jpg") + .originalFilename("test.jpg") + .contentType("image/jpeg") + .fileSize(1024L) + .displayOrder(0) + .build(); + } + + @Nested + @DisplayName("POST /api/accommodation/{accommodationId}/photos") + class UploadPhotoEndpoint { + + @Test + @DisplayName("With valid request returns 201") + void upload_WithValidRequest_Returns201() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + .thenReturn(createPhotoResponse()); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(PHOTO_ID.toString())) + .andExpect(jsonPath("$.originalFilename").value("test.jpg")); + } + + @Test + @DisplayName("With display order parameter uses it") + void upload_WithDisplayOrder_UsesParameter() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), eq(5), any(UserContext.class))) + .thenReturn(createPhotoResponse()); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .param("displayOrder", "5") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isCreated()); + + verify(photoService).uploadPhoto(eq(ACCOMMODATION_ID), any(), eq(5), any(UserContext.class)); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void upload_WithMissingAuthHeaders_Returns401() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With GUEST role returns 403") + void upload_WithGuestRole_Returns403() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With accommodation not found returns 404") + void upload_WithAccommodationNotFound_Returns404() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + .thenThrow(new AccommodationNotFoundException("Not found")); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With invalid content type returns 400") + void upload_WithInvalidContentType_Returns400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.gif", "image/gif", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + .thenThrow(new InvalidContentTypeException("Invalid content type")); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With photo limit exceeded returns 400") + void upload_WithPhotoLimitExceeded_Returns400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class))) + .thenThrow(new PhotoLimitExceededException("Limit exceeded")); + + mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{accommodationId}/photos") + class ListPhotosEndpoint { + + @Test + @DisplayName("Returns 200 with list of photos") + void list_Returns200WithList() throws Exception { + when(photoService.listPhotos(ACCOMMODATION_ID)) + .thenReturn(List.of(createPhotoResponse())); + + mockMvc.perform(get("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(PHOTO_ID.toString())) + .andExpect(jsonPath("$[0].originalFilename").value("test.jpg")); + } + + @Test + @DisplayName("With accommodation not found returns 404") + void list_WithAccommodationNotFound_Returns404() throws Exception { + when(photoService.listPhotos(ACCOMMODATION_ID)) + .thenThrow(new AccommodationNotFoundException("Not found")); + + mockMvc.perform(get("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{accommodationId}/photos/{photoId}") + class GetPhotoEndpoint { + + @Test + @DisplayName("Returns 200 with image file") + void get_Returns200WithImageFile() throws Exception { + AccommodationPhoto photo = createPhotoEntity(); + byte[] content = "test image content".getBytes(); + + when(photoService.getPhotoMetadata(ACCOMMODATION_ID, PHOTO_ID)).thenReturn(photo); + when(photoService.getPhotoFile(ACCOMMODATION_ID, PHOTO_ID)) + .thenReturn(new ByteArrayInputStream(content)); + + mockMvc.perform(get("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.IMAGE_JPEG)) + .andExpect(header().longValue("Content-Length", photo.getFileSize())); + } + + @Test + @DisplayName("With photo not found returns 404") + void get_WithPhotoNotFound_Returns404() throws Exception { + when(photoService.getPhotoMetadata(ACCOMMODATION_ID, PHOTO_ID)) + .thenThrow(new PhotoNotFoundException("Not found")); + + mockMvc.perform(get("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("DELETE /api/accommodation/{accommodationId}/photos/{photoId}") + class DeletePhotoEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void delete_WithValidRequest_Returns204() throws Exception { + doNothing().when(photoService).deletePhoto(eq(ACCOMMODATION_ID), eq(PHOTO_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void delete_WithMissingAuthHeaders_Returns401() throws Exception { + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With GUEST role returns 403") + void delete_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With photo not found returns 404") + void delete_WithPhotoNotFound_Returns404() throws Exception { + doThrow(new PhotoNotFoundException("Not found")) + .when(photoService).deletePhoto(eq(ACCOMMODATION_ID), eq(PHOTO_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void delete_WithWrongOwner_Returns403() throws Exception { + doThrow(new ForbiddenException("Not the owner")) + .when(photoService).deletePhoto(eq(ACCOMMODATION_ID), eq(PHOTO_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/accommodation/{accommodationId}/photos/{photoId}", ACCOMMODATION_ID, PHOTO_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java index a1e702c..14ee471 100644 --- a/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationIntegrationTest.java @@ -11,6 +11,7 @@ import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MinIOContainer; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -36,6 +37,9 @@ class AccommodationIntegrationTest { .withUsername("test") .withPassword("test"); + @Container + static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2024-01-31T20-20-33Z"); + @Autowired private MockMvc mockMvc; @@ -55,6 +59,12 @@ static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.flyway.url", postgres::getJdbcUrl); registry.add("spring.flyway.user", postgres::getUsername); registry.add("spring.flyway.password", postgres::getPassword); + + // MinIO + registry.add("minio.endpoint", minio::getS3URL); + registry.add("minio.access-key", minio::getUserName); + registry.add("minio.secret-key", minio::getPassword); + registry.add("minio.bucket", () -> "test-accommodation-photos"); } private Map validCreateRequest() { diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java new file mode 100644 index 0000000..c59a736 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java @@ -0,0 +1,286 @@ +package com.devoops.accommodation.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AccommodationPhotoIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("accommodation_db_test") + .withUsername("test") + .withPassword("test"); + + @Container + static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2024-01-31T20-20-33Z"); + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static String accommodationId; + private static String photoId; + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID OTHER_HOST_ID = UUID.randomUUID(); + + private static final String ACCOMMODATION_BASE_PATH = "/api/accommodation"; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + // PostgreSQL + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + + // MinIO + registry.add("minio.endpoint", minio::getS3URL); + registry.add("minio.access-key", minio::getUserName); + registry.add("minio.secret-key", minio::getPassword); + registry.add("minio.bucket", () -> "test-accommodation-photos"); + } + + private String photosPath() { + return ACCOMMODATION_BASE_PATH + "/" + accommodationId + "/photos"; + } + + @Test + @Order(1) + @DisplayName("Create accommodation for photo tests") + void setup_CreateAccommodation() throws Exception { + var request = Map.of( + "name", "Photo Test Apartment", + "address", "123 Photo St", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + MvcResult result = mockMvc.perform(post(ACCOMMODATION_BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + accommodationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(2) + @DisplayName("Upload photo with valid request returns 201") + void uploadPhoto_WithValidRequest_Returns201() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test-image.jpg", "image/jpeg", + "fake image content".getBytes()); + + MvcResult result = mockMvc.perform(multipart(photosPath()) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.accommodationId").value(accommodationId)) + .andExpect(jsonPath("$.originalFilename").value("test-image.jpg")) + .andExpect(jsonPath("$.contentType").value("image/jpeg")) + .andExpect(jsonPath("$.displayOrder").value(0)) + .andReturn(); + + photoId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(3) + @DisplayName("Upload photo with custom display order uses provided order") + void uploadPhoto_WithDisplayOrder_UsesProvidedOrder() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "second-image.png", "image/png", + "fake image content 2".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file) + .param("displayOrder", "5") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.displayOrder").value(5)); + } + + @Test + @Order(4) + @DisplayName("Upload photo without auth headers returns 401") + void uploadPhoto_WithoutAuthHeaders_Returns401() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file)) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(5) + @DisplayName("Upload photo with GUEST role returns 403") + void uploadPhoto_WithGuestRole_Returns403() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(6) + @DisplayName("Upload photo with different host returns 403") + void uploadPhoto_WithDifferentHost_Returns403() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "content".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file) + .header("X-User-Id", OTHER_HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(7) + @DisplayName("Upload photo with invalid content type returns 400") + void uploadPhoto_WithInvalidContentType_Returns400() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.gif", "image/gif", "content".getBytes()); + + mockMvc.perform(multipart(photosPath()) + .file(file) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(8) + @DisplayName("List photos returns 200 with photo list") + void listPhotos_Returns200WithPhotoList() throws Exception { + mockMvc.perform(get(photosPath())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(2)))) + .andExpect(jsonPath("$[0].originalFilename").value("test-image.jpg")); + } + + @Test + @Order(9) + @DisplayName("List photos for non-existing accommodation returns 404") + void listPhotos_NonExistingAccommodation_Returns404() throws Exception { + mockMvc.perform(get(ACCOMMODATION_BASE_PATH + "/" + UUID.randomUUID() + "/photos")) + .andExpect(status().isNotFound()); + } + + @Test + @Order(10) + @DisplayName("Get photo file returns 200 with image content") + void getPhotoFile_Returns200WithImageContent() throws Exception { + mockMvc.perform(get(photosPath() + "/" + photoId)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.IMAGE_JPEG)) + .andExpect(header().exists("Content-Length")); + } + + @Test + @Order(11) + @DisplayName("Get photo file for non-existing photo returns 404") + void getPhotoFile_NonExistingPhoto_Returns404() throws Exception { + mockMvc.perform(get(photosPath() + "/" + UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + @Test + @Order(12) + @DisplayName("Delete photo without auth headers returns 401") + void deletePhoto_WithoutAuthHeaders_Returns401() throws Exception { + mockMvc.perform(delete(photosPath() + "/" + photoId)) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(13) + @DisplayName("Delete photo with GUEST role returns 403") + void deletePhoto_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(delete(photosPath() + "/" + photoId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(14) + @DisplayName("Delete photo with different host returns 403") + void deletePhoto_WithDifferentHost_Returns403() throws Exception { + mockMvc.perform(delete(photosPath() + "/" + photoId) + .header("X-User-Id", OTHER_HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(15) + @DisplayName("Delete photo with valid owner returns 204") + void deletePhoto_WithValidOwner_Returns204() throws Exception { + mockMvc.perform(delete(photosPath() + "/" + photoId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @Order(16) + @DisplayName("After delete, get photo returns 404 (soft-delete filters)") + void deletePhoto_ThenGet_Returns404() throws Exception { + mockMvc.perform(get(photosPath() + "/" + photoId)) + .andExpect(status().isNotFound()); + } + + @Test + @Order(17) + @DisplayName("After delete, list photos excludes deleted photo") + void deletePhoto_ThenList_ExcludesDeletedPhoto() throws Exception { + mockMvc.perform(get(photosPath())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[?(@.id == '" + photoId + "')]").doesNotExist()); + } +} diff --git a/src/test/java/com/devoops/accommodation/service/AccommodationPhotoServiceTest.java b/src/test/java/com/devoops/accommodation/service/AccommodationPhotoServiceTest.java new file mode 100644 index 0000000..c381fc0 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/AccommodationPhotoServiceTest.java @@ -0,0 +1,339 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.response.AccommodationPhotoResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AccommodationPhoto; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.mapper.AccommodationPhotoMapper; +import com.devoops.accommodation.repository.AccommodationPhotoRepository; +import com.devoops.accommodation.repository.AccommodationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +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.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AccommodationPhotoServiceTest { + + @Mock + private AccommodationPhotoRepository photoRepository; + + @Mock + private AccommodationRepository accommodationRepository; + + @Mock + private PhotoStorageService photoStorageService; + + @Mock + private AccommodationPhotoMapper photoMapper; + + @InjectMocks + private AccommodationPhotoService photoService; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID OTHER_HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID PHOTO_ID = UUID.randomUUID(); + private static final UserContext HOST_CONTEXT = new UserContext(HOST_ID, "HOST"); + private static final UserContext OTHER_HOST_CONTEXT = new UserContext(OTHER_HOST_ID, "HOST"); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(photoService, "maxPhotosPerAccommodation", 20); + ReflectionTestUtils.setField(photoService, "allowedContentTypesConfig", "image/jpeg,image/png,image/webp"); + } + + private Accommodation createAccommodation() { + return Accommodation.builder() + .id(ACCOMMODATION_ID) + .hostId(HOST_ID) + .name("Test Apartment") + .address("123 Test St") + .minGuests(1) + .maxGuests(4) + .pricingMode(PricingMode.PER_GUEST) + .approvalMode(ApprovalMode.MANUAL) + .build(); + } + + private AccommodationPhoto createPhoto() { + return AccommodationPhoto.builder() + .id(PHOTO_ID) + .accommodationId(ACCOMMODATION_ID) + .storageFilename("uuid-filename.jpg") + .originalFilename("original.jpg") + .contentType("image/jpeg") + .fileSize(1024L) + .displayOrder(0) + .build(); + } + + private AccommodationPhotoResponse createPhotoResponse() { + return new AccommodationPhotoResponse( + PHOTO_ID, ACCOMMODATION_ID, "original.jpg", "image/jpeg", + 1024L, 0, LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Nested + @DisplayName("uploadPhoto") + class UploadPhotoTests { + + @Test + @DisplayName("With valid request uploads photo and returns response") + void uploadPhoto_ValidRequest_ReturnsResponse() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + var accommodation = createAccommodation(); + var photo = createPhoto(); + var response = createPhotoResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.countByAccommodationId(ACCOMMODATION_ID)).thenReturn(0L); + when(photoStorageService.store(file)).thenReturn("uuid-filename.jpg"); + when(photoRepository.findMaxDisplayOrder(ACCOMMODATION_ID)).thenReturn(-1); + when(photoRepository.saveAndFlush(any(AccommodationPhoto.class))).thenReturn(photo); + when(photoMapper.toResponse(photo)).thenReturn(response); + + AccommodationPhotoResponse result = photoService.uploadPhoto(ACCOMMODATION_ID, file, null, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + verify(photoStorageService).store(file); + verify(photoRepository).saveAndFlush(any(AccommodationPhoto.class)); + } + + @Test + @DisplayName("With custom display order uses provided order") + void uploadPhoto_CustomDisplayOrder_UsesProvidedOrder() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + var accommodation = createAccommodation(); + var photo = createPhoto(); + var response = createPhotoResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.countByAccommodationId(ACCOMMODATION_ID)).thenReturn(0L); + when(photoStorageService.store(file)).thenReturn("uuid-filename.jpg"); + when(photoRepository.saveAndFlush(any(AccommodationPhoto.class))).thenReturn(photo); + when(photoMapper.toResponse(photo)).thenReturn(response); + + photoService.uploadPhoto(ACCOMMODATION_ID, file, 5, HOST_CONTEXT); + + verify(photoRepository, never()).findMaxDisplayOrder(any()); + } + + @Test + @DisplayName("With accommodation not found throws AccommodationNotFoundException") + void uploadPhoto_AccommodationNotFound_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.uploadPhoto(ACCOMMODATION_ID, file, null, HOST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void uploadPhoto_WrongOwner_ThrowsForbiddenException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> photoService.uploadPhoto(ACCOMMODATION_ID, file, null, OTHER_HOST_CONTEXT)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("not the owner"); + } + + @Test + @DisplayName("With invalid content type throws InvalidContentTypeException") + void uploadPhoto_InvalidContentType_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.gif", "image/gif", "test content".getBytes()); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> photoService.uploadPhoto(ACCOMMODATION_ID, file, null, HOST_CONTEXT)) + .isInstanceOf(InvalidContentTypeException.class) + .hasMessageContaining("image/gif"); + } + + @Test + @DisplayName("With photo limit reached throws PhotoLimitExceededException") + void uploadPhoto_PhotoLimitReached_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.countByAccommodationId(ACCOMMODATION_ID)).thenReturn(20L); + + assertThatThrownBy(() -> photoService.uploadPhoto(ACCOMMODATION_ID, file, null, HOST_CONTEXT)) + .isInstanceOf(PhotoLimitExceededException.class) + .hasMessageContaining("Maximum number of photos"); + } + } + + @Nested + @DisplayName("listPhotos") + class ListPhotosTests { + + @Test + @DisplayName("Returns list of photo responses") + void listPhotos_ReturnsPhotoResponses() { + var accommodation = createAccommodation(); + var photos = List.of(createPhoto()); + var responses = List.of(createPhotoResponse()); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.findByAccommodationIdOrderByDisplayOrderAsc(ACCOMMODATION_ID)).thenReturn(photos); + when(photoMapper.toResponseList(photos)).thenReturn(responses); + + List result = photoService.listPhotos(ACCOMMODATION_ID); + + assertThat(result).hasSize(1); + assertThat(result.get(0)).isEqualTo(responses.get(0)); + } + + @Test + @DisplayName("With accommodation not found throws AccommodationNotFoundException") + void listPhotos_AccommodationNotFound_ThrowsException() { + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.listPhotos(ACCOMMODATION_ID)) + .isInstanceOf(AccommodationNotFoundException.class); + } + } + + @Nested + @DisplayName("getPhotoFile") + class GetPhotoFileTests { + + @Test + @DisplayName("Returns input stream for photo file") + void getPhotoFile_ReturnsInputStream() { + var photo = createPhoto(); + InputStream mockStream = new ByteArrayInputStream("test content".getBytes()); + + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.of(photo)); + when(photoStorageService.loadAsStream(photo.getStorageFilename())).thenReturn(mockStream); + + InputStream result = photoService.getPhotoFile(ACCOMMODATION_ID, PHOTO_ID); + + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("With photo not found throws PhotoNotFoundException") + void getPhotoFile_PhotoNotFound_ThrowsException() { + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.getPhotoFile(ACCOMMODATION_ID, PHOTO_ID)) + .isInstanceOf(PhotoNotFoundException.class); + } + } + + @Nested + @DisplayName("getPhotoMetadata") + class GetPhotoMetadataTests { + + @Test + @DisplayName("Returns photo entity") + void getPhotoMetadata_ReturnsPhotoEntity() { + var photo = createPhoto(); + + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.of(photo)); + + AccommodationPhoto result = photoService.getPhotoMetadata(ACCOMMODATION_ID, PHOTO_ID); + + assertThat(result).isEqualTo(photo); + } + + @Test + @DisplayName("With photo not found throws PhotoNotFoundException") + void getPhotoMetadata_PhotoNotFound_ThrowsException() { + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.getPhotoMetadata(ACCOMMODATION_ID, PHOTO_ID)) + .isInstanceOf(PhotoNotFoundException.class); + } + } + + @Nested + @DisplayName("deletePhoto") + class DeletePhotoTests { + + @Test + @DisplayName("With valid owner soft-deletes photo and removes from storage") + void deletePhoto_ValidOwner_SoftDeletesPhoto() { + var accommodation = createAccommodation(); + var photo = createPhoto(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.of(photo)); + + photoService.deletePhoto(ACCOMMODATION_ID, PHOTO_ID, HOST_CONTEXT); + + assertThat(photo.isDeleted()).isTrue(); + verify(photoStorageService).delete(photo.getStorageFilename()); + verify(photoRepository).save(photo); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void deletePhoto_WrongOwner_ThrowsForbiddenException() { + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> photoService.deletePhoto(ACCOMMODATION_ID, PHOTO_ID, OTHER_HOST_CONTEXT)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With accommodation not found throws AccommodationNotFoundException") + void deletePhoto_AccommodationNotFound_ThrowsException() { + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.deletePhoto(ACCOMMODATION_ID, PHOTO_ID, HOST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class); + } + + @Test + @DisplayName("With photo not found throws PhotoNotFoundException") + void deletePhoto_PhotoNotFound_ThrowsException() { + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(photoRepository.findByIdAndAccommodationId(PHOTO_ID, ACCOMMODATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> photoService.deletePhoto(ACCOMMODATION_ID, PHOTO_ID, HOST_CONTEXT)) + .isInstanceOf(PhotoNotFoundException.class); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/service/PhotoStorageServiceTest.java b/src/test/java/com/devoops/accommodation/service/PhotoStorageServiceTest.java new file mode 100644 index 0000000..e46e444 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/PhotoStorageServiceTest.java @@ -0,0 +1,188 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.exception.PhotoStorageException; +import io.minio.*; +import io.minio.errors.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +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.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PhotoStorageServiceTest { + + @Mock + private MinioClient minioClient; + + private PhotoStorageService photoStorageService; + + private static final String BUCKET_NAME = "test-bucket"; + + @BeforeEach + void setUp() { + photoStorageService = new PhotoStorageService(minioClient); + ReflectionTestUtils.setField(photoStorageService, "bucketName", BUCKET_NAME); + } + + @Nested + @DisplayName("init") + class InitTests { + + @Test + @DisplayName("Creates bucket if it does not exist") + void init_BucketDoesNotExist_CreatesBucket() throws Exception { + when(minioClient.bucketExists(any(BucketExistsArgs.class))).thenReturn(false); + + photoStorageService.init(); + + verify(minioClient).makeBucket(any(MakeBucketArgs.class)); + } + + @Test + @DisplayName("Does not create bucket if it already exists") + void init_BucketExists_DoesNotCreateBucket() throws Exception { + when(minioClient.bucketExists(any(BucketExistsArgs.class))).thenReturn(true); + + photoStorageService.init(); + + verify(minioClient, never()).makeBucket(any(MakeBucketArgs.class)); + } + + @Test + @DisplayName("Throws PhotoStorageException on MinIO error") + void init_MinioError_ThrowsPhotoStorageException() throws Exception { + when(minioClient.bucketExists(any(BucketExistsArgs.class))) + .thenThrow(new RuntimeException("Connection failed")); + + assertThatThrownBy(() -> photoStorageService.init()) + .isInstanceOf(PhotoStorageException.class) + .hasMessageContaining("Failed to initialize photo storage"); + } + } + + @Nested + @DisplayName("store") + class StoreTests { + + @Test + @DisplayName("Stores file and returns object key with extension") + void store_ValidFile_ReturnsObjectKeyWithExtension() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test-image.jpg", "image/jpeg", "test content".getBytes()); + + String objectKey = photoStorageService.store(file); + + assertThat(objectKey).endsWith(".jpg"); + assertThat(objectKey).matches("[a-f0-9\\-]+\\.jpg"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PutObjectArgs.class); + verify(minioClient).putObject(captor.capture()); + assertThat(captor.getValue().bucket()).isEqualTo(BUCKET_NAME); + assertThat(captor.getValue().contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("Stores file without extension when original filename has no extension") + void store_FileWithoutExtension_ReturnsObjectKeyWithoutExtension() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "testimage", "image/jpeg", "test content".getBytes()); + + String objectKey = photoStorageService.store(file); + + assertThat(objectKey).doesNotContain("."); + verify(minioClient).putObject(any(PutObjectArgs.class)); + } + + @Test + @DisplayName("Throws PhotoStorageException on MinIO error") + void store_MinioError_ThrowsPhotoStorageException() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "test.jpg", "image/jpeg", "test content".getBytes()); + + doThrow(new RuntimeException("Upload failed")) + .when(minioClient).putObject(any(PutObjectArgs.class)); + + assertThatThrownBy(() -> photoStorageService.store(file)) + .isInstanceOf(PhotoStorageException.class) + .hasMessageContaining("Failed to store photo"); + } + } + + @Nested + @DisplayName("loadAsStream") + class LoadAsStreamTests { + + @Test + @DisplayName("Returns input stream for existing object") + void loadAsStream_ExistingObject_ReturnsInputStream() throws Exception { + String objectKey = "test-object.jpg"; + InputStream mockStream = new ByteArrayInputStream("test content".getBytes()); + GetObjectResponse mockResponse = mock(GetObjectResponse.class); + + when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockResponse); + + InputStream result = photoStorageService.loadAsStream(objectKey); + + assertThat(result).isNotNull(); + verify(minioClient).getObject(any(GetObjectArgs.class)); + } + + @Test + @DisplayName("Throws PhotoStorageException on MinIO error") + void loadAsStream_MinioError_ThrowsPhotoStorageException() throws Exception { + String objectKey = "nonexistent.jpg"; + + when(minioClient.getObject(any(GetObjectArgs.class))) + .thenThrow(new RuntimeException("Object not found")); + + assertThatThrownBy(() -> photoStorageService.loadAsStream(objectKey)) + .isInstanceOf(PhotoStorageException.class) + .hasMessageContaining("Failed to load photo"); + } + } + + @Nested + @DisplayName("delete") + class DeleteTests { + + @Test + @DisplayName("Deletes object from bucket") + void delete_ExistingObject_DeletesFromBucket() throws Exception { + String objectKey = "test-object.jpg"; + + photoStorageService.delete(objectKey); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RemoveObjectArgs.class); + verify(minioClient).removeObject(captor.capture()); + assertThat(captor.getValue().bucket()).isEqualTo(BUCKET_NAME); + assertThat(captor.getValue().object()).isEqualTo(objectKey); + } + + @Test + @DisplayName("Throws PhotoStorageException on MinIO error") + void delete_MinioError_ThrowsPhotoStorageException() throws Exception { + String objectKey = "test-object.jpg"; + + doThrow(new RuntimeException("Delete failed")) + .when(minioClient).removeObject(any(RemoveObjectArgs.class)); + + assertThatThrownBy(() -> photoStorageService.delete(objectKey)) + .isInstanceOf(PhotoStorageException.class) + .hasMessageContaining("Failed to delete photo"); + } + } +}