Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,27 @@
<scope>test</scope>
</dependency>

<!-- Testcontainers for PostgreSQL integration testing -->
<!-- Version 1.21.4+ required for Docker Desktop 4.50+ (current Docker version: 29.1.3) -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>

<!-- DevTools for development -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand All @@ -101,6 +122,40 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>

<!-- Surefire for unit tests (exclude integration tests) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<excludes>
<exclude>**/*IntegrationTest.java</exclude>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
</plugin>

<!-- Failsafe for integration tests -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<includes>
<include>**/*IntegrationTest.java</include>
<include>**/*IT.java</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
6 changes: 3 additions & 3 deletions src/main/java/com/photoalbum/repository/PhotoRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ public interface PhotoRepository extends JpaRepository<Photo, String> {
@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<Photo> findPhotosByUploadMonth(@Param("year") String year, @Param("month") String month);

Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
}
Loading