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