diff --git a/core/gradle/wrapper/gradle-wrapper.jar b/core/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/core/gradle/wrapper/gradle-wrapper.jar differ diff --git a/core/gradlew.bat b/core/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/core/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/core/src/main/java/com/opencontext/common/CommonResponse.java b/core/src/main/java/com/opencontext/common/CommonResponse.java new file mode 100644 index 0000000..61de039 --- /dev/null +++ b/core/src/main/java/com/opencontext/common/CommonResponse.java @@ -0,0 +1,85 @@ +package com.opencontext.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * Standard API response structure class. + * Handles success and failure responses in a consistent format to simplify client response processing. + * + * This class should only be created through static factory methods, + * preventing direct constructor usage to ensure API response consistency. + */ +@Schema(description = "Standard API response structure") +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CommonResponse { + + @Schema(description = "Request success status", example = "true") + private final boolean success; + + @Schema(description = "Response data (null on failure)") + private final T data; + + @Schema(description = "Message to display to user", example = "Request processed successfully.") + private final String message; + + @Schema(description = "Error code (null on success)", example = "VALIDATION_FAILED") + private final String errorCode; + + @Schema(description = "Response creation timestamp", example = "2025-08-07T12:00:00") + private final LocalDateTime timestamp; + + /** + * Private constructor. Only allows instance creation through static factory methods. + */ + private CommonResponse(boolean success, T data, String message, String errorCode, LocalDateTime timestamp) { + this.success = success; + this.data = data; + this.message = message; + this.errorCode = errorCode; + this.timestamp = timestamp; + } + + /** + * Static factory method for creating success responses. + */ + public static CommonResponse success(T data) { + return new CommonResponse<>( + true, + data, + "Request processed successfully.", + null, + LocalDateTime.now() + ); + } + + /** + * Static factory method for creating success responses with custom message. + */ + public static CommonResponse success(T data, String message) { + return new CommonResponse<>( + true, + data, + message, + null, + LocalDateTime.now() + ); + } + + /** + * Static factory method for creating error responses. + */ + public static CommonResponse error(String message, String errorCode) { + return new CommonResponse<>( + false, + null, + message, + errorCode, + LocalDateTime.now() + ); + } +} diff --git a/core/src/main/java/com/opencontext/common/PageResponse.java b/core/src/main/java/com/opencontext/common/PageResponse.java new file mode 100644 index 0000000..a2e54a7 --- /dev/null +++ b/core/src/main/java/com/opencontext/common/PageResponse.java @@ -0,0 +1,85 @@ +package com.opencontext.common; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * Paginated list response DTO. + * Converts Spring Data Page objects into a client-friendly format. + */ +@Schema(description = "Paginated list response DTO") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class PageResponse { + + @Schema(description = "Data list for current page") + private List content; + + @Schema(description = "Current page number (zero-based)", example = "0") + private int page; + + @Schema(description = "Number of items per page", example = "10") + private int size; + + @Schema(description = "Total number of items", example = "100") + private long totalElements; + + @Schema(description = "Total number of pages", example = "10") + private int totalPages; + + @Schema(description = "Whether this is the first page", example = "true") + private boolean first; + + @Schema(description = "Whether this is the last page", example = "false") + private boolean last; + + @Schema(description = "Whether there is a next page", example = "true") + private boolean hasNext; + + @Schema(description = "Whether there is a previous page", example = "false") + private boolean hasPrevious; + + /** + * Static factory method to convert Page object to PageResponse DTO. + */ + public static PageResponse from(Page page) { + return PageResponse.builder() + .content(page.getContent()) + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .first(page.isFirst()) + .last(page.isLast()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); + } + + /** + * Static factory method to convert Page object with custom content to PageResponse DTO. + */ + public static PageResponse from(Page page, List content) { + return PageResponse.builder() + .content(content) + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .first(page.isFirst()) + .last(page.isLast()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); + } +} + diff --git a/core/src/main/java/com/opencontext/config/AsyncConfig.java b/core/src/main/java/com/opencontext/config/AsyncConfig.java new file mode 100644 index 0000000..bdc8a6e --- /dev/null +++ b/core/src/main/java/com/opencontext/config/AsyncConfig.java @@ -0,0 +1,49 @@ +package com.opencontext.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * Asynchronous processing configuration. + * Configuration for executing heavy operations like document ingestion pipeline + * in separate threads. + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + /** + * Dedicated thread pool configuration for document ingestion pipeline. + */ + @Bean(name = "ingestionTaskExecutor") + public Executor ingestionTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); // Number of concurrent document ingestion tasks + executor.setMaxPoolSize(4); // Maximum number of threads + executor.setQueueCapacity(10); // Queue capacity for waiting tasks + executor.setThreadNamePrefix("ingestion-"); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + + /** + * Default thread pool configuration for general asynchronous tasks. + */ + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(20); + executor.setThreadNamePrefix("async-"); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} + diff --git a/core/src/main/java/com/opencontext/config/JpaAuditingConfig.java b/core/src/main/java/com/opencontext/config/JpaAuditingConfig.java new file mode 100644 index 0000000..97f6933 --- /dev/null +++ b/core/src/main/java/com/opencontext/config/JpaAuditingConfig.java @@ -0,0 +1,14 @@ +package com.opencontext.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA Auditing configuration. + * Enables automatic field updates for @CreatedDate, @LastModifiedDate, etc. + * in entities with @EntityListeners(AuditingEntityListener.class). + */ +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/core/src/main/java/com/opencontext/config/WebConfig.java b/core/src/main/java/com/opencontext/config/WebConfig.java new file mode 100644 index 0000000..8e03e5e --- /dev/null +++ b/core/src/main/java/com/opencontext/config/WebConfig.java @@ -0,0 +1,28 @@ +package com.opencontext.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web-related configuration. + * Handles web-related settings such as CORS, interceptors, resource handlers, etc. + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + /** + * CORS configuration. + * Allows CORS for communication with frontend in development environment. + */ + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns("*") // Allow all origins in development environment + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS","PATCH") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} + diff --git a/core/src/main/java/com/opencontext/enums/ErrorCode.java b/core/src/main/java/com/opencontext/enums/ErrorCode.java new file mode 100644 index 0000000..cc63386 --- /dev/null +++ b/core/src/main/java/com/opencontext/enums/ErrorCode.java @@ -0,0 +1,45 @@ +package com.opencontext.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** + * Enum defining all possible error codes in the system. + * Each error code provides an HTTP status code along with machine-parsable code + * and human-readable message. + */ +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // --- COMMON --- + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_001", "Invalid request."), + VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "COMMON_002", "Input validation failed."), + UNKNOWN_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_003", "Unknown server error occurred."), + + // --- AUTHENTICATION --- + INSUFFICIENT_PERMISSION(HttpStatus.FORBIDDEN, "AUTH_001", "Insufficient permission to perform this request."), + + // --- DOCUMENT --- + SOURCE_DOCUMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "DOC_001", "Document with the specified ID not found."), + DUPLICATE_FILE_UPLOADED(HttpStatus.CONFLICT, "DOC_002", "A file with identical content already exists."), + RESOURCE_IS_BEING_PROCESSED(HttpStatus.CONFLICT, "DOC_003", "The document is currently being processed by another operation."), + PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "DOC_004", "File size exceeds the maximum upload limit."), + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "DOC_005", "Unsupported file format. Please upload PDF or Markdown files."), + + // --- CONTEXT/SEARCH --- + TOKEN_LIMIT_EXCEEDED(HttpStatus.UNPROCESSABLE_ENTITY, "CTX_001", "Requested content token count exceeds maximum limit."), + CHUNK_NOT_FOUND(HttpStatus.NOT_FOUND, "CTX_002", "Chunk with the specified ID not found."), + + // --- INFRASTRUCTURE/EXTERNAL SERVICES --- + INGESTION_PIPELINE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "INFRA_001", "Internal error occurred during document processing."), + EXTERNAL_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "INFRA_002", "External service is not responding."), + DB_CONNECTION_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "INFRA_003", "Database connection failed."), + ELASTICSEARCH_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "INFRA_004", "Search engine error occurred."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} + diff --git a/core/src/main/java/com/opencontext/enums/IngestionStatus.java b/core/src/main/java/com/opencontext/enums/IngestionStatus.java new file mode 100644 index 0000000..a0dc630 --- /dev/null +++ b/core/src/main/java/com/opencontext/enums/IngestionStatus.java @@ -0,0 +1,63 @@ +package com.opencontext.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Enum representing the progress states of the document ingestion pipeline. + * Each state clearly indicates how documents are being processed in the system. + */ +@Getter +@RequiredArgsConstructor +public enum IngestionStatus { + /** + * Initial state waiting for processing after user upload request. + * The system will start processing soon. + */ + PENDING("Waiting for processing"), + + /** + * Analyzing file text and structure through Unstructured API. + * This step may take time depending on file complexity. + */ + PARSING("Analyzing document structure"), + + /** + * Splitting parsed content into hierarchical chunks by semantic units. + * This is the stage where the document's logical structure is created. + */ + CHUNKING("Creating content chunks"), + + /** + * Converting each chunk to vectors through embedding model. + * This is the stage where core data for semantic search is generated. + */ + EMBEDDING("Generating semantic vectors"), + + /** + * Storing vectors and metadata in Elasticsearch, hierarchy info in PostgreSQL. + * This is the final data storage step to make content searchable. + */ + INDEXING("Storing search data"), + + /** + * Final state where all ingestion processes are successfully completed and searchable. + * Users can now search the content of this document. + */ + COMPLETED("Ready for search"), + + /** + * State where an unrecoverable error occurred during the ingestion process. + * Details are recorded in the 'error_message' column. + */ + ERROR("Processing failed"), + + /** + * Removing all related data (MinIO, PostgreSQL, Elasticsearch) after deletion request. + * No other operations can be performed during this state. + */ + DELETING("Removing data"); + + private final String description; +} + diff --git a/core/src/main/java/com/opencontext/exception/BusinessException.java b/core/src/main/java/com/opencontext/exception/BusinessException.java new file mode 100644 index 0000000..f207a94 --- /dev/null +++ b/core/src/main/java/com/opencontext/exception/BusinessException.java @@ -0,0 +1,47 @@ +package com.opencontext.exception; + +import com.opencontext.enums.ErrorCode; +import lombok.Getter; + +/** + * Class representing predictable exceptions that occur in business logic. + * This exception is used to provide clear error information to clients. + */ +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + /** + * Creates BusinessException using ErrorCode. + */ + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + /** + * Creates BusinessException using ErrorCode and custom message. + */ + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + /** + * Creates BusinessException using ErrorCode, custom message, and cause exception. + */ + public BusinessException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + /** + * Creates BusinessException using ErrorCode and cause exception. + */ + public BusinessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } +} + diff --git a/core/src/main/java/com/opencontext/exception/GlobalExceptionHandler.java b/core/src/main/java/com/opencontext/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..545364f --- /dev/null +++ b/core/src/main/java/com/opencontext/exception/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.opencontext.exception; + +import com.opencontext.common.CommonResponse; +import com.opencontext.enums.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Global exception handler. + * Converts exceptions from all controllers into consistent CommonResponse format. + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * Handles business logic related exceptions. + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + ErrorCode errorCode = ex.getErrorCode(); + log.warn("BusinessException occurred: code={}, message={}", errorCode.getCode(), ex.getMessage()); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(CommonResponse.error(ex.getMessage(), errorCode.getCode())); + } + + /** + * Handles @Valid annotation validation failures. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + ErrorCode errorCode = ErrorCode.VALIDATION_FAILED; + String firstErrorMessage = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + log.warn("Validation failed: {}", firstErrorMessage); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(CommonResponse.error(firstErrorMessage, errorCode.getCode())); + } + + /** + * Handles all unexpected server errors. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAllUncaughtException(Exception ex) { + ErrorCode errorCode = ErrorCode.UNKNOWN_SERVER_ERROR; + log.error("Unknown server error occurred", ex); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(CommonResponse.error(errorCode.getMessage(), errorCode.getCode())); + } +} + diff --git a/core/src/test/java/com/opencontext/common/CommonResponseTest.java b/core/src/test/java/com/opencontext/common/CommonResponseTest.java new file mode 100644 index 0000000..06a3f6b --- /dev/null +++ b/core/src/test/java/com/opencontext/common/CommonResponseTest.java @@ -0,0 +1,69 @@ +package com.opencontext.common; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for CommonResponse class. + */ +class CommonResponseTest { + + @Test + @DisplayName("Success response creation should return correct structure") + void success_shouldReturnCorrectStructure() { + // Given + String testData = "test data"; + + // When + CommonResponse response = CommonResponse.success(testData); + + // Then + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEqualTo(testData); + assertThat(response.getMessage()).isEqualTo("Request processed successfully."); + assertThat(response.getErrorCode()).isNull(); + assertThat(response.getTimestamp()).isNotNull(); + assertThat(response.getTimestamp()).isInstanceOf(LocalDateTime.class); + } + + @Test + @DisplayName("Success response creation with custom message should return custom message") + void successWithCustomMessage_shouldReturnCustomMessage() { + // Given + String testData = "test data"; + String customMessage = "Custom success message"; + + // When + CommonResponse response = CommonResponse.success(testData, customMessage); + + // Then + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEqualTo(testData); + assertThat(response.getMessage()).isEqualTo(customMessage); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + @DisplayName("Error response creation should return correct structure") + void error_shouldReturnCorrectStructure() { + // Given + String errorMessage = "Test error message"; + String errorCode = "TEST_ERROR"; + + // When + CommonResponse response = CommonResponse.error(errorMessage, errorCode); + + // Then + assertThat(response.isSuccess()).isFalse(); + assertThat(response.getData()).isNull(); + assertThat(response.getMessage()).isEqualTo(errorMessage); + assertThat(response.getErrorCode()).isEqualTo(errorCode); + assertThat(response.getTimestamp()).isNotNull(); + assertThat(response.getTimestamp()).isInstanceOf(LocalDateTime.class); + } +} + diff --git a/core/src/test/java/com/opencontext/common/PageResponseTest.java b/core/src/test/java/com/opencontext/common/PageResponseTest.java new file mode 100644 index 0000000..724ab94 --- /dev/null +++ b/core/src/test/java/com/opencontext/common/PageResponseTest.java @@ -0,0 +1,60 @@ +package com.opencontext.common; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for PageResponse class. + */ +class PageResponseTest { + + @Test + @DisplayName("Should return correct information when creating PageResponse from Page object") + void fromPage_shouldReturnCorrectInformation() { + // Given + List content = List.of("item1", "item2", "item3"); + PageRequest pageRequest = PageRequest.of(0, 10); + Page page = new PageImpl<>(content, pageRequest, 25); + + // When + PageResponse response = PageResponse.from(page); + + // Then + assertThat(response.getContent()).isEqualTo(content); + assertThat(response.getPage()).isEqualTo(0); + assertThat(response.getSize()).isEqualTo(10); + assertThat(response.getTotalElements()).isEqualTo(25); + assertThat(response.getTotalPages()).isEqualTo(3); + assertThat(response.isFirst()).isTrue(); + assertThat(response.isLast()).isFalse(); + } + + @Test + @DisplayName("Should return correct information when creating PageResponse from Page object with custom content") + void fromPageWithCustomContent_shouldReturnCorrectInformation() { + // Given + List originalContent = List.of("item1", "item2", "item3"); + List customContent = List.of("custom1", "custom2"); + PageRequest pageRequest = PageRequest.of(1, 10); + Page page = new PageImpl<>(originalContent, pageRequest, 25); + + // When + PageResponse response = PageResponse.from(page, customContent); + + // Then + assertThat(response.getContent()).isEqualTo(customContent); + assertThat(response.getPage()).isEqualTo(1); + assertThat(response.getSize()).isEqualTo(10); + assertThat(response.getTotalElements()).isEqualTo(25); + assertThat(response.getTotalPages()).isEqualTo(3); + assertThat(response.isFirst()).isFalse(); + assertThat(response.isLast()).isFalse(); + } +}