diff --git a/pom.xml b/pom.xml index 207abb3..6894664 100644 --- a/pom.xml +++ b/pom.xml @@ -87,6 +87,27 @@ test + + + + org.testcontainers + testcontainers + 1.21.4 + test + + + org.testcontainers + junit-jupiter + 1.21.4 + test + + + org.testcontainers + postgresql + 1.21.4 + test + + org.springframework.boot @@ -101,6 +122,40 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + **/*IntegrationTest.java + **/*IT.java + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 2.22.2 + + + **/*IntegrationTest.java + **/*IT.java + + + + + + integration-test + verify + + + + \ No newline at end of file diff --git a/src/main/java/com/photoalbum/repository/PhotoRepository.java b/src/main/java/com/photoalbum/repository/PhotoRepository.java index 28edeff..408d620 100644 --- a/src/main/java/com/photoalbum/repository/PhotoRepository.java +++ b/src/main/java/com/photoalbum/repository/PhotoRepository.java @@ -67,9 +67,9 @@ public interface PhotoRepository extends JpaRepository { @Query(value = "SELECT id, original_file_name, photo_data, stored_file_name, file_path, file_size, " + "mime_type, uploaded_at, width, height " + "FROM photos " + - "WHERE EXTRACT(YEAR FROM uploaded_at)::text = :year " + - "AND LPAD(EXTRACT(MONTH FROM uploaded_at)::text, 2, '0') = :month " + - "ORDER BY uploaded_at DESC", + "WHERE CAST(EXTRACT(YEAR FROM uploaded_at) AS text) = :year " + + "AND LPAD(CAST(EXTRACT(MONTH FROM uploaded_at) AS text), 2, '0') = :month " + + "ORDER BY uploaded_at DESC", nativeQuery = true) List findPhotosByUploadMonth(@Param("year") String year, @Param("month") String month); diff --git a/src/test/java/com/photoalbum/integration/AbstractPostgreSQLIntegrationTest.java b/src/test/java/com/photoalbum/integration/AbstractPostgreSQLIntegrationTest.java new file mode 100644 index 0000000..bd56ab0 --- /dev/null +++ b/src/test/java/com/photoalbum/integration/AbstractPostgreSQLIntegrationTest.java @@ -0,0 +1,44 @@ +package com.photoalbum.integration; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Abstract base class for PostgreSQL integration tests using Testcontainers. + * This class provides a shared PostgreSQL container that simulates the production + * PostgreSQL database for integration testing purposes. + * + * All integration test classes should extend this class to share the same + * PostgreSQL container configuration. + */ +@Testcontainers +public abstract class AbstractPostgreSQLIntegrationTest { + + /** + * PostgreSQL container using version 15 (alpine for faster startup). + * Container is shared across all tests in the same JVM for better performance. + */ + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15-alpine") + .withDatabaseName("photoalbum_test") + .withUsername("testuser") + .withPassword("testpassword"); + + /** + * Dynamically configure Spring datasource properties to point to the Testcontainer. + * This overrides application properties to use the containerized PostgreSQL. + */ + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.show-sql", () -> "true"); + } +} diff --git a/src/test/java/com/photoalbum/integration/controller/PhotoControllerIntegrationTest.java b/src/test/java/com/photoalbum/integration/controller/PhotoControllerIntegrationTest.java new file mode 100644 index 0000000..c777c8a --- /dev/null +++ b/src/test/java/com/photoalbum/integration/controller/PhotoControllerIntegrationTest.java @@ -0,0 +1,238 @@ +package com.photoalbum.integration.controller; + +import com.photoalbum.integration.AbstractPostgreSQLIntegrationTest; +import com.photoalbum.model.Photo; +import com.photoalbum.repository.PhotoRepository; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integration tests for photo-related controllers using PostgreSQL Testcontainer. + * Tests verify end-to-end HTTP request handling with a real PostgreSQL database. + */ +@SpringBootTest +@AutoConfigureMockMvc +class PhotoControllerIntegrationTest extends AbstractPostgreSQLIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private PhotoRepository photoRepository; + + private Photo existingPhoto; + + @BeforeEach + void setUp() throws IOException { + photoRepository.deleteAll(); + + // Create a test photo with real valid image data + existingPhoto = new Photo(); + existingPhoto.setOriginalFileName("test_photo.jpg"); + existingPhoto.setStoredFileName("stored_test_photo.jpg"); + existingPhoto.setFilePath("/uploads/stored_test_photo.jpg"); + existingPhoto.setMimeType("image/jpeg"); + existingPhoto.setPhotoData(createMinimalValidJpeg()); + existingPhoto.setFileSize((long) existingPhoto.getPhotoData().length); + existingPhoto.setWidth(10); + existingPhoto.setHeight(10); + existingPhoto.setUploadedAt(LocalDateTime.now()); + existingPhoto = photoRepository.save(existingPhoto); + } + + /** + * Creates a minimal valid JPEG image using BufferedImage. + * Following Pattern 3 - create realistic test data, not just magic bytes. + */ + private byte[] createMinimalValidJpeg() throws IOException { + BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "jpg", baos); + return baos.toByteArray(); + } + + @Nested + @DisplayName("Home Controller - Gallery Page") + class HomeControllerTests { + + @Test + @DisplayName("should render gallery page with photos from PostgreSQL") + void shouldRenderGalleryPageWithPhotos() throws Exception { + // Act & Assert + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(view().name("index")) + .andExpect(model().attributeExists("photos")) + .andExpect(model().attributeExists("timestamp")); + } + + @Test + @DisplayName("should render empty gallery when no photos exist") + void shouldRenderEmptyGallery() throws Exception { + // Arrange + photoRepository.deleteAll(); + + // Act & Assert + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(view().name("index")) + .andExpect(model().attributeExists("photos")); + } + } + + @Nested + @DisplayName("Home Controller - Photo Upload") + class PhotoUploadTests { + + @Test + @DisplayName("should upload photo successfully and store in PostgreSQL") + void shouldUploadPhotoSuccessfully() throws Exception { + // Arrange + byte[] validImageData = createMinimalValidJpeg(); + MockMultipartFile file = new MockMultipartFile( + "files", + "new_photo.jpg", + "image/jpeg", + validImageData + ); + + // Act & Assert + MvcResult result = mockMvc.perform(multipart("/upload").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.uploadedPhotos").isArray()) + .andExpect(jsonPath("$.uploadedPhotos[0].id").exists()) + .andExpect(jsonPath("$.uploadedPhotos[0].originalFileName").value("new_photo.jpg")) + .andReturn(); + + // Verify photo was saved to PostgreSQL + long count = photoRepository.count(); + assertThat(count).isEqualTo(2); // existing + new + } + + @Test + @DisplayName("should upload multiple photos successfully") + void shouldUploadMultiplePhotos() throws Exception { + // Arrange + byte[] validImageData = createMinimalValidJpeg(); + MockMultipartFile file1 = new MockMultipartFile( + "files", + "photo1.jpg", + "image/jpeg", + validImageData + ); + MockMultipartFile file2 = new MockMultipartFile( + "files", + "photo2.png", + "image/png", + createMinimalValidPng() + ); + + // Act & Assert + mockMvc.perform(multipart("/upload").file(file1).file(file2)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.uploadedPhotos").isArray()) + .andExpect(jsonPath("$.uploadedPhotos.length()").value(2)); + + // Verify photos saved + long count = photoRepository.count(); + assertThat(count).isEqualTo(3); // 1 existing + 2 new + } + + @Test + @DisplayName("should reject unsupported file type") + void shouldRejectUnsupportedFileType() throws Exception { + // Arrange + MockMultipartFile file = new MockMultipartFile( + "files", + "document.pdf", + "application/pdf", + "PDF content".getBytes() + ); + + // Act & Assert + mockMvc.perform(multipart("/upload").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.failedUploads").isArray()) + .andExpect(jsonPath("$.failedUploads[0].error").exists()); + } + + /** + * Creates a minimal valid PNG image. + */ + private byte[] createMinimalValidPng() throws IOException { + BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return baos.toByteArray(); + } + } + + @Nested + @DisplayName("Photo File Controller - Serving Photos") + class PhotoFileControllerTests { + + @Test + @DisplayName("should serve photo binary data from PostgreSQL") + void shouldServePhotoBinaryData() throws Exception { + // Act & Assert - Use contentTypeCompatibleWith to ignore charset (Pattern 4) + mockMvc.perform(get("/photo/{id}", existingPhoto.getId())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.IMAGE_JPEG)) + .andExpect(header().string("X-Photo-ID", existingPhoto.getId())) + .andExpect(header().string("X-Photo-Name", "test_photo.jpg")) + .andExpect(content().bytes(existingPhoto.getPhotoData())); + } + + @Test + @DisplayName("should return 404 when photo not found") + void shouldReturn404WhenPhotoNotFound() throws Exception { + // Act & Assert + mockMvc.perform(get("/photo/{id}", "non-existent-id")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should return 404 for empty photo ID") + void shouldReturn404ForEmptyId() throws Exception { + // Act & Assert + mockMvc.perform(get("/photo/{id}", " ")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("should include cache control headers") + void shouldIncludeCacheControlHeaders() throws Exception { + // Act & Assert + mockMvc.perform(get("/photo/{id}", existingPhoto.getId())) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, + "no-cache, no-store, must-revalidate, private")) + .andExpect(header().string(HttpHeaders.PRAGMA, "no-cache")) + .andExpect(header().string(HttpHeaders.EXPIRES, "0")); + } + } +} diff --git a/src/test/java/com/photoalbum/integration/repository/PhotoRepositoryIntegrationTest.java b/src/test/java/com/photoalbum/integration/repository/PhotoRepositoryIntegrationTest.java new file mode 100644 index 0000000..273438f --- /dev/null +++ b/src/test/java/com/photoalbum/integration/repository/PhotoRepositoryIntegrationTest.java @@ -0,0 +1,381 @@ +package com.photoalbum.integration.repository; + +import com.photoalbum.integration.AbstractPostgreSQLIntegrationTest; +import com.photoalbum.model.Photo; +import com.photoalbum.repository.PhotoRepository; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for PhotoRepository using PostgreSQL Testcontainer. + * Tests verify that JPA repository operations work correctly with PostgreSQL, + * including PostgreSQL-specific queries with EXTRACT, LIMIT/OFFSET, and window functions. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class PhotoRepositoryIntegrationTest extends AbstractPostgreSQLIntegrationTest { + + @Autowired + private PhotoRepository photoRepository; + + private Photo testPhoto1; + private Photo testPhoto2; + private Photo testPhoto3; + + @BeforeEach + void setUp() { + photoRepository.deleteAll(); + + // Create test photos with different timestamps + testPhoto1 = createPhoto("photo1.jpg", "image/jpeg", 1024L); + testPhoto1.setUploadedAt(LocalDateTime.now().minusDays(2)); + testPhoto1 = photoRepository.save(testPhoto1); + + testPhoto2 = createPhoto("photo2.png", "image/png", 2048L); + testPhoto2.setUploadedAt(LocalDateTime.now().minusDays(1)); + testPhoto2 = photoRepository.save(testPhoto2); + + testPhoto3 = createPhoto("photo3.gif", "image/gif", 512L); + testPhoto3.setUploadedAt(LocalDateTime.now()); + testPhoto3 = photoRepository.save(testPhoto3); + } + + private Photo createPhoto(String originalFileName, String mimeType, Long fileSize) { + Photo photo = new Photo(); + photo.setOriginalFileName(originalFileName); + photo.setStoredFileName("stored_" + originalFileName); + photo.setFilePath("/uploads/stored_" + originalFileName); + photo.setMimeType(mimeType); + photo.setFileSize(fileSize); + photo.setPhotoData(createTestPhotoData(fileSize.intValue())); + photo.setWidth(800); + photo.setHeight(600); + return photo; + } + + /** + * Creates test photo data with proper byte casting to avoid lossy conversion errors. + */ + private byte[] createTestPhotoData(int size) { + byte[] data = new byte[size]; + for (int i = 0; i < size; i++) { + // Use explicit cast to byte to avoid lossy conversion error + data[i] = (byte) (i % 256); + } + return data; + } + + @Nested + @DisplayName("Basic CRUD Operations") + class CrudOperations { + + @Test + @DisplayName("should save and retrieve photo by ID") + void shouldSaveAndRetrievePhotoById() { + // Arrange + Photo newPhoto = createPhoto("new_photo.jpg", "image/jpeg", 3000L); + + // Act + Photo savedPhoto = photoRepository.save(newPhoto); + Optional retrievedPhoto = photoRepository.findById(savedPhoto.getId()); + + // Assert + assertThat(retrievedPhoto).isPresent(); + assertThat(retrievedPhoto.get().getOriginalFileName()).isEqualTo("new_photo.jpg"); + assertThat(retrievedPhoto.get().getMimeType()).isEqualTo("image/jpeg"); + assertThat(retrievedPhoto.get().getFileSize()).isEqualTo(3000L); + } + + @Test + @DisplayName("should return empty when photo not found") + void shouldReturnEmptyWhenPhotoNotFound() { + // Act + Optional result = photoRepository.findById("non-existent-id"); + + // Assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should delete photo successfully") + void shouldDeletePhotoSuccessfully() { + // Arrange + String photoId = testPhoto1.getId(); + + // Act + photoRepository.deleteById(photoId); + Optional result = photoRepository.findById(photoId); + + // Assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("should update photo successfully") + void shouldUpdatePhotoSuccessfully() { + // Arrange + testPhoto1.setOriginalFileName("updated_photo.jpg"); + testPhoto1.setWidth(1920); + testPhoto1.setHeight(1080); + + // Act + Photo updatedPhoto = photoRepository.save(testPhoto1); + + // Assert + assertThat(updatedPhoto.getOriginalFileName()).isEqualTo("updated_photo.jpg"); + assertThat(updatedPhoto.getWidth()).isEqualTo(1920); + assertThat(updatedPhoto.getHeight()).isEqualTo(1080); + } + + @Test + @DisplayName("should store and retrieve binary photo data (BYTEA)") + void shouldStoreAndRetrieveBinaryPhotoData() { + // Arrange - PNG header bytes with explicit casts + byte[] imageData = new byte[]{ + (byte) 0x89, (byte) 0x50, (byte) 0x4E, (byte) 0x47, + (byte) 0x0D, (byte) 0x0A, (byte) 0x1A, (byte) 0x0A + }; + Photo photo = createPhoto("binary_test.png", "image/png", (long) imageData.length); + photo.setPhotoData(imageData); + + // Act + Photo savedPhoto = photoRepository.save(photo); + Optional retrievedPhoto = photoRepository.findById(savedPhoto.getId()); + + // Assert + assertThat(retrievedPhoto).isPresent(); + assertThat(retrievedPhoto.get().getPhotoData()).isEqualTo(imageData); + } + } + + @Nested + @DisplayName("Custom Query Methods") + class CustomQueryMethods { + + @Test + @DisplayName("should find all photos ordered by upload date descending") + void shouldFindAllPhotosOrderedByUploadDateDesc() { + // Act + List photos = photoRepository.findAllOrderByUploadedAtDesc(); + + // Assert + assertThat(photos).hasSize(3); + assertThat(photos.get(0).getId()).isEqualTo(testPhoto3.getId()); + assertThat(photos.get(1).getId()).isEqualTo(testPhoto2.getId()); + assertThat(photos.get(2).getId()).isEqualTo(testPhoto1.getId()); + } + + @Test + @DisplayName("should find photos uploaded before a given timestamp") + void shouldFindPhotosUploadedBefore() { + // Act + List photos = photoRepository.findPhotosUploadedBefore(testPhoto3.getUploadedAt()); + + // Assert + assertThat(photos).hasSize(2); + assertThat(photos.get(0).getId()).isEqualTo(testPhoto2.getId()); + assertThat(photos.get(1).getId()).isEqualTo(testPhoto1.getId()); + } + + @Test + @DisplayName("should find photos uploaded after a given timestamp") + void shouldFindPhotosUploadedAfter() { + // Act + List photos = photoRepository.findPhotosUploadedAfter(testPhoto1.getUploadedAt()); + + // Assert + assertThat(photos).hasSize(2); + // Ordered by ASC, so oldest first + assertThat(photos.get(0).getId()).isEqualTo(testPhoto2.getId()); + assertThat(photos.get(1).getId()).isEqualTo(testPhoto3.getId()); + } + + @Test + @DisplayName("should return empty list when no photos before timestamp") + void shouldReturnEmptyListWhenNoPhotosBefore() { + // Act + List photos = photoRepository.findPhotosUploadedBefore(testPhoto1.getUploadedAt()); + + // Assert + assertThat(photos).isEmpty(); + } + + @Test + @DisplayName("should return empty list when no photos after timestamp") + void shouldReturnEmptyListWhenNoPhotosAfter() { + // Act + List photos = photoRepository.findPhotosUploadedAfter(testPhoto3.getUploadedAt()); + + // Assert + assertThat(photos).isEmpty(); + } + } + + @Nested + @DisplayName("PostgreSQL-Specific Query Methods") + class PostgreSQLSpecificQueries { + + @Test + @DisplayName("should find photos by upload month using EXTRACT function") + void shouldFindPhotosByUploadMonth() { + // Arrange + LocalDateTime now = LocalDateTime.now(); + String year = String.valueOf(now.getYear()); + String month = String.format("%02d", now.getMonthValue()); + + // Act + List photos = photoRepository.findPhotosByUploadMonth(year, month); + + // Assert + assertThat(photos).isNotEmpty(); + assertThat(photos).contains(testPhoto3); + } + + @Test + @DisplayName("should find photos with pagination using LIMIT/OFFSET") + void shouldFindPhotosWithPagination() { + // Act - Get first page (2 items) + List firstPage = photoRepository.findPhotosWithPagination(2, 0); + + // Act - Get second page (remaining items) + List secondPage = photoRepository.findPhotosWithPagination(2, 2); + + // Assert + assertThat(firstPage).hasSize(2); + assertThat(firstPage.get(0).getId()).isEqualTo(testPhoto3.getId()); + assertThat(firstPage.get(1).getId()).isEqualTo(testPhoto2.getId()); + + assertThat(secondPage).hasSize(1); + assertThat(secondPage.get(0).getId()).isEqualTo(testPhoto1.getId()); + } + + @Test + @DisplayName("should find photos with statistics using window functions") + void shouldFindPhotosWithStatistics() { + // Act + List results = photoRepository.findPhotosWithStatistics(); + + // Assert + assertThat(results).hasSize(3); + + // Verify that window function columns are present (size_rank and running_total) + for (Object[] row : results) { + assertThat(row.length).isGreaterThanOrEqualTo(11); // 10 photo columns + 2 computed + } + } + + @Test + @DisplayName("should handle empty pagination gracefully") + void shouldHandleEmptyPaginationGracefully() { + // Act - Request page beyond available data + List emptyPage = photoRepository.findPhotosWithPagination(10, 100); + + // Assert + assertThat(emptyPage).isEmpty(); + } + } + + @Nested + @DisplayName("Large Binary Data (BYTEA) Tests") + class LargeBinaryDataTests { + + @Test + @DisplayName("should store and retrieve large photo data") + void shouldStoreAndRetrieveLargePhotoData() { + // Arrange - Create 1MB of test data + int size = 1024 * 1024; // 1MB + byte[] largeData = new byte[size]; + for (int i = 0; i < size; i++) { + // Use explicit cast to avoid lossy conversion + largeData[i] = (byte) (i % 256); + } + + Photo photo = createPhoto("large_photo.jpg", "image/jpeg", (long) size); + photo.setPhotoData(largeData); + + // Act + Photo savedPhoto = photoRepository.save(photo); + Optional retrievedPhoto = photoRepository.findById(savedPhoto.getId()); + + // Assert + assertThat(retrievedPhoto).isPresent(); + assertThat(retrievedPhoto.get().getPhotoData()).hasSize(size); + assertThat(retrievedPhoto.get().getPhotoData()).isEqualTo(largeData); + } + + @Test + @DisplayName("should handle null photo data") + void shouldHandleNullPhotoData() { + // Arrange + Photo photo = createPhoto("no_data.jpg", "image/jpeg", 0L); + photo.setPhotoData(null); + + // Act + Photo savedPhoto = photoRepository.save(photo); + Optional retrievedPhoto = photoRepository.findById(savedPhoto.getId()); + + // Assert + assertThat(retrievedPhoto).isPresent(); + assertThat(retrievedPhoto.get().getPhotoData()).isNull(); + } + } + + @Nested + @DisplayName("Timestamp Handling Tests") + class TimestampHandlingTests { + + @Test + @DisplayName("should preserve timestamp precision") + void shouldPreserveTimestampPrecision() { + // Arrange + LocalDateTime preciseTimestamp = LocalDateTime.of(2024, 6, 15, 14, 30, 45, 123456789); + Photo photo = createPhoto("timestamp_test.jpg", "image/jpeg", 100L); + photo.setUploadedAt(preciseTimestamp); + + // Act + Photo savedPhoto = photoRepository.save(photo); + Optional retrievedPhoto = photoRepository.findById(savedPhoto.getId()); + + // Assert + assertThat(retrievedPhoto).isPresent(); + // PostgreSQL stores microseconds, so we compare up to that precision + LocalDateTime retrieved = retrievedPhoto.get().getUploadedAt(); + assertThat(retrieved.getYear()).isEqualTo(2024); + assertThat(retrieved.getMonthValue()).isEqualTo(6); + assertThat(retrieved.getDayOfMonth()).isEqualTo(15); + assertThat(retrieved.getHour()).isEqualTo(14); + assertThat(retrieved.getMinute()).isEqualTo(30); + assertThat(retrieved.getSecond()).isEqualTo(45); + } + + @Test + @DisplayName("should auto-generate uploadedAt on creation") + void shouldAutoGenerateUploadedAtOnCreation() { + // Arrange + Photo photo = new Photo(); + photo.setOriginalFileName("auto_timestamp.jpg"); + photo.setStoredFileName("stored_auto_timestamp.jpg"); + photo.setFilePath("/uploads/stored_auto_timestamp.jpg"); + photo.setMimeType("image/jpeg"); + photo.setFileSize(100L); + + // Act + Photo savedPhoto = photoRepository.save(photo); + + // Assert + assertThat(savedPhoto.getUploadedAt()).isNotNull(); + assertThat(savedPhoto.getUploadedAt()).isBeforeOrEqualTo(LocalDateTime.now()); + } + } +} diff --git a/src/test/java/com/photoalbum/integration/service/PhotoServiceIntegrationTest.java b/src/test/java/com/photoalbum/integration/service/PhotoServiceIntegrationTest.java new file mode 100644 index 0000000..87612fc --- /dev/null +++ b/src/test/java/com/photoalbum/integration/service/PhotoServiceIntegrationTest.java @@ -0,0 +1,351 @@ +package com.photoalbum.integration.service; + +import com.photoalbum.integration.AbstractPostgreSQLIntegrationTest; +import com.photoalbum.model.Photo; +import com.photoalbum.model.UploadResult; +import com.photoalbum.repository.PhotoRepository; +import com.photoalbum.service.PhotoService; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for PhotoService using PostgreSQL Testcontainer. + * Tests verify that the service layer works correctly with PostgreSQL, + * including photo upload with binary storage, retrieval, deletion, and navigation. + */ +@SpringBootTest +class PhotoServiceIntegrationTest extends AbstractPostgreSQLIntegrationTest { + + @Autowired + private PhotoService photoService; + + @Autowired + private PhotoRepository photoRepository; + + private Photo existingPhoto1; + private Photo existingPhoto2; + private Photo existingPhoto3; + + @BeforeEach + void setUp() { + photoRepository.deleteAll(); + + // Create existing photos for navigation tests + existingPhoto1 = createAndSavePhoto("existing1.jpg", "image/jpeg", 1000L, + LocalDateTime.now().minusDays(3)); + existingPhoto2 = createAndSavePhoto("existing2.png", "image/png", 2000L, + LocalDateTime.now().minusDays(2)); + existingPhoto3 = createAndSavePhoto("existing3.gif", "image/gif", 3000L, + LocalDateTime.now().minusDays(1)); + } + + private Photo createAndSavePhoto(String fileName, String mimeType, Long fileSize, + LocalDateTime uploadedAt) { + Photo photo = new Photo(); + photo.setOriginalFileName(fileName); + photo.setStoredFileName("stored_" + fileName); + photo.setFilePath("/uploads/stored_" + fileName); + photo.setMimeType(mimeType); + photo.setFileSize(fileSize); + photo.setPhotoData(createTestPhotoData(fileSize.intValue())); + photo.setWidth(800); + photo.setHeight(600); + photo.setUploadedAt(uploadedAt); + return photoRepository.save(photo); + } + + /** + * Creates test photo data with proper byte casting to avoid lossy conversion errors. + */ + private byte[] createTestPhotoData(int size) { + byte[] data = new byte[size]; + for (int i = 0; i < size; i++) { + data[i] = (byte) (i % 256); + } + return data; + } + + @Nested + @DisplayName("Get All Photos") + class GetAllPhotosTests { + + @Test + @DisplayName("should return all photos ordered by upload date descending") + void shouldReturnAllPhotosOrderedByUploadDateDesc() { + // Act + List photos = photoService.getAllPhotos(); + + // Assert + assertThat(photos).hasSize(3); + assertThat(photos.get(0).getId()).isEqualTo(existingPhoto3.getId()); + assertThat(photos.get(1).getId()).isEqualTo(existingPhoto2.getId()); + assertThat(photos.get(2).getId()).isEqualTo(existingPhoto1.getId()); + } + + @Test + @DisplayName("should return empty list when no photos exist") + void shouldReturnEmptyListWhenNoPhotosExist() { + // Arrange + photoRepository.deleteAll(); + + // Act + List photos = photoService.getAllPhotos(); + + // Assert + assertThat(photos).isEmpty(); + } + } + + @Nested + @DisplayName("Get Photo By ID") + class GetPhotoByIdTests { + + @Test + @DisplayName("should return photo when ID exists") + void shouldReturnPhotoWhenIdExists() { + // Act + Optional result = photoService.getPhotoById(existingPhoto1.getId()); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get().getOriginalFileName()).isEqualTo("existing1.jpg"); + } + + @Test + @DisplayName("should return empty when ID does not exist") + void shouldReturnEmptyWhenIdDoesNotExist() { + // Act + Optional result = photoService.getPhotoById("non-existent-id"); + + // Assert + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("Upload Photo") + class UploadPhotoTests { + + @Test + @DisplayName("should upload JPEG photo successfully to PostgreSQL") + void shouldUploadJpegPhotoSuccessfully() throws IOException { + // Arrange - Create minimal valid JPEG image + byte[] jpegData = createMinimalValidJpeg(); + MockMultipartFile file = new MockMultipartFile( + "file", + "test_upload.jpg", + "image/jpeg", + jpegData + ); + + // Act + UploadResult result = photoService.uploadPhoto(file); + + // Assert + assertThat(result.isSuccess()) + .as("Upload should succeed. Error: %s", result.getErrorMessage()) + .isTrue(); + assertThat(result.getPhotoId()).isNotNull(); + assertThat(result.getErrorMessage()).isNull(); + + // Verify photo was saved to PostgreSQL + Optional savedPhoto = photoRepository.findById(result.getPhotoId()); + assertThat(savedPhoto).isPresent(); + assertThat(savedPhoto.get().getOriginalFileName()).isEqualTo("test_upload.jpg"); + assertThat(savedPhoto.get().getMimeType()).isEqualTo("image/jpeg"); + assertThat(savedPhoto.get().getPhotoData()).hasSize(jpegData.length); + } + + @Test + @DisplayName("should upload PNG photo successfully") + void shouldUploadPngPhotoSuccessfully() throws IOException { + // Arrange - Create minimal valid PNG image + byte[] pngData = createMinimalValidPng(); + MockMultipartFile file = new MockMultipartFile( + "file", + "test_upload.png", + "image/png", + pngData + ); + + // Act + UploadResult result = photoService.uploadPhoto(file); + + // Assert + assertThat(result.isSuccess()) + .as("Upload should succeed. Error: %s", result.getErrorMessage()) + .isTrue(); + assertThat(result.getPhotoId()).isNotNull(); + } + + @Test + @DisplayName("should reject unsupported file type") + void shouldRejectUnsupportedFileType() { + // Arrange + MockMultipartFile file = new MockMultipartFile( + "file", + "document.pdf", + "application/pdf", + "PDF content".getBytes() + ); + + // Act + UploadResult result = photoService.uploadPhoto(file); + + // Assert + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getErrorMessage()).contains("not supported"); + } + + @Test + @DisplayName("should reject file exceeding size limit") + void shouldRejectFileTooLarge() { + // Arrange - Create 11MB file (exceeds 10MB limit) + byte[] largeData = new byte[11 * 1024 * 1024]; + MockMultipartFile file = new MockMultipartFile( + "file", + "huge_photo.jpg", + "image/jpeg", + largeData + ); + + // Act + UploadResult result = photoService.uploadPhoto(file); + + // Assert + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getErrorMessage()).contains("exceeds"); + } + + @Test + @DisplayName("should reject empty file") + void shouldRejectEmptyFile() { + // Arrange + MockMultipartFile file = new MockMultipartFile( + "file", + "empty.jpg", + "image/jpeg", + new byte[0] + ); + + // Act + UploadResult result = photoService.uploadPhoto(file); + + // Assert + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getErrorMessage()).contains("empty"); + } + + /** + * Creates a minimal valid JPEG image using BufferedImage. + * Following Pattern 3 best practice - create realistic test data. + */ + private byte[] createMinimalValidJpeg() throws IOException { + BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "jpg", baos); + return baos.toByteArray(); + } + + /** + * Creates a minimal valid PNG image using BufferedImage. + */ + private byte[] createMinimalValidPng() throws IOException { + BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return baos.toByteArray(); + } + } + + @Nested + @DisplayName("Delete Photo") + class DeletePhotoTests { + + @Test + @DisplayName("should delete existing photo from PostgreSQL") + void shouldDeleteExistingPhoto() { + // Arrange + String photoId = existingPhoto1.getId(); + + // Act + boolean result = photoService.deletePhoto(photoId); + + // Assert + assertThat(result).isTrue(); + assertThat(photoRepository.findById(photoId)).isEmpty(); + } + + @Test + @DisplayName("should return false when deleting non-existent photo") + void shouldReturnFalseWhenDeletingNonExistentPhoto() { + // Act + boolean result = photoService.deletePhoto("non-existent-id"); + + // Assert + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("Photo Navigation") + class PhotoNavigationTests { + + @Test + @DisplayName("should return previous (older) photo") + void shouldReturnPreviousPhoto() { + // Act + Optional previous = photoService.getPreviousPhoto(existingPhoto3); + + // Assert + assertThat(previous).isPresent(); + assertThat(previous.get().getId()).isEqualTo(existingPhoto2.getId()); + } + + @Test + @DisplayName("should return next (newer) photo") + void shouldReturnNextPhoto() { + // Act + Optional next = photoService.getNextPhoto(existingPhoto1); + + // Assert + assertThat(next).isPresent(); + assertThat(next.get().getId()).isEqualTo(existingPhoto2.getId()); + } + + @Test + @DisplayName("should return empty when no previous photo exists") + void shouldReturnEmptyWhenNoPreviousPhoto() { + // Act + Optional previous = photoService.getPreviousPhoto(existingPhoto1); + + // Assert + assertThat(previous).isEmpty(); + } + + @Test + @DisplayName("should return empty when no next photo exists") + void shouldReturnEmptyWhenNoNextPhoto() { + // Act + Optional next = photoService.getNextPhoto(existingPhoto3); + + // Assert + assertThat(next).isEmpty(); + } + } +} diff --git a/src/test/resources/application-integration-test.properties b/src/test/resources/application-integration-test.properties new file mode 100644 index 0000000..446c977 --- /dev/null +++ b/src/test/resources/application-integration-test.properties @@ -0,0 +1,17 @@ +# Integration test configuration using PostgreSQL Testcontainers +# Note: Database connection properties are dynamically set by Testcontainers via @DynamicPropertySource + +# JPA Configuration for PostgreSQL integration tests +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# File upload configuration for testing +app.file-upload.max-file-size-bytes=10485760 +app.file-upload.allowed-mime-types=image/jpeg,image/png,image/gif,image/webp +app.file-upload.max-files-per-upload=10 + +# Logging for integration tests +logging.level.com.photoalbum=DEBUG +logging.level.org.testcontainers=INFO +logging.level.org.springframework.jdbc=DEBUG