added unit tests

This commit is contained in:
Dario Ghunney Ware 2025-06-11 15:18:43 +01:00
parent 1490b29ec0
commit f2468d26c9
44 changed files with 1378 additions and 302 deletions

View File

@ -473,6 +473,7 @@ spotless {
target sourceSets.main.allJava target sourceSets.main.allJava
target project(':common').sourceSets.main.allJava target project(':common').sourceSets.main.allJava
target project(':proprietary').sourceSets.main.allJava target project(':proprietary').sourceSets.main.allJava
target project(':stirling-pdf').sourceSets.main.allJava
googleJavaFormat("1.27.0").aosp().reorderImports(false) googleJavaFormat("1.27.0").aosp().reorderImports(false)

View File

@ -1,7 +1,5 @@
package stirling.software.common.configuration; package stirling.software.common.configuration;
import io.github.pixee.security.SystemCommand;
import jakarta.annotation.PostConstruct;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -10,29 +8,22 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Properties; import java.util.Properties;
import java.util.function.Predicate; import java.util.function.Predicate;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.Scope;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.ResourceLoader;
import org.thymeleaf.spring6.SpringTemplateEngine; import org.thymeleaf.spring6.SpringTemplateEngine;
import org.springframework.core.Ordered;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
@Lazy @Lazy

View File

@ -0,0 +1,83 @@
package stirling.software.proprietary.security.service;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.repository.TeamRepository;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TeamServiceTest {
@Mock
private TeamRepository teamRepository;
@InjectMocks
private TeamService teamService;
@Test
void getDefaultTeam() {
var team = new Team();
team.setName("Marleyans");
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
.thenReturn(Optional.of(team));
Team result = teamService.getOrCreateDefaultTeam();
assertEquals(team, result);
}
@Test
void createDefaultTeam_whenRepositoryIsEmpty() {
String teamName = "Default";
var defaultTeam = new Team();
defaultTeam.setId(1L);
defaultTeam.setName(teamName);
when(teamRepository.findByName(teamName))
.thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(defaultTeam);
Team result = teamService.getOrCreateDefaultTeam();
assertEquals(TeamService.DEFAULT_TEAM_NAME, result.getName());
}
@Test
void getInternalTeam() {
var team = new Team();
team.setName("Eldians");
when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME))
.thenReturn(Optional.of(team));
Team result = teamService.getOrCreateInternalTeam();
assertEquals(team, result);
}
@Test
void createInternalTeam_whenRepositoryIsEmpty() {
String teamName = "Internal";
Team internalTeam = new Team();
internalTeam.setId(2L);
internalTeam.setName(teamName);
when(teamRepository.findByName(teamName))
.thenReturn(Optional.empty());
when(teamRepository.save(any(Team.class))).thenReturn(internalTeam);
when(teamRepository.findByName(TeamService.INTERNAL_TEAM_NAME))
.thenReturn(Optional.empty());
Team result = teamService.getOrCreateInternalTeam();
assertEquals(internalTeam, result);
}
}

View File

@ -0,0 +1,317 @@
package stirling.software.proprietary.security.service;
import java.sql.SQLException;
import java.util.Locale;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private TeamRepository teamRepository;
@Mock
private AuthorityRepository authorityRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private MessageSource messageSource;
@Mock
private SessionPersistentRegistry sessionPersistentRegistry;
@Mock
private DatabaseServiceInterface databaseService;
@Mock
private ApplicationProperties.Security.OAUTH2 oauth2Properties;
@InjectMocks
private UserService userService;
private Team mockTeam;
private User mockUser;
@BeforeEach
void setUp() {
mockTeam = new Team();
mockTeam.setId(1L);
mockTeam.setName("Test Team");
mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testuser");
mockUser.setEnabled(true);
}
@Test
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception {
// Given
String username = "testuser";
AuthenticationType authType = AuthenticationType.WEB;
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, authType);
// Then
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, password, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(teamRepository).findById(teamId);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithTeamAndRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
String role = Role.ADMIN.getRoleId();
boolean firstLogin = true;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, password, mockTeam, role, firstLogin);
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception {
// Given
String invalidUsername = "ab"; // Too short (less than 3 characters)
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_WithNullPassword_Success() throws Exception {
// Given
String username = "testuser";
Long teamId = 1L;
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, null, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithEmptyPassword_Success() throws Exception {
// Given
String username = "testuser";
String emptyPassword = "";
Long teamId = 1L;
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, emptyPassword, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithValidEmail_Success() throws Exception {
// Given
String emailUsername = "test@example.com";
AuthenticationType authType = AuthenticationType.SSO;
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(emailUsername, authType);
// Then
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithReservedUsername_ThrowsException() throws Exception {
// Given
String reservedUsername = "all_users";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception {
// Given
String anonymousUsername = "anonymoususer";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType)
);
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase();
// When & Then
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
// Verify user was still saved before the exception
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithFirstLoginFlag_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
boolean firstLogin = true;
boolean enabled = false;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, firstLogin, enabled);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithCustomRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String customRole = Role.LIMITED_API_USER.getRoleId();
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, customRole);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
}

View File

@ -13,8 +13,6 @@ import java.util.Properties;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@ -210,7 +208,7 @@ public class SPDFApplication {
if (arg.startsWith("--spring.profiles.active=")) { if (arg.startsWith("--spring.profiles.active=")) {
String[] provided = arg.substring(arg.indexOf('=') + 1).split(","); String[] provided = arg.substring(arg.indexOf('=') + 1).split(",");
if (provided.length > 0) { if (provided.length > 0) {
log.info("#######0000000000000###############################"); log.info("#######0000000000000###############################");
return provided; return provided;
} }
} }
@ -218,14 +216,16 @@ public class SPDFApplication {
} }
log.info("######################################"); log.info("######################################");
// 2. Detect if SecurityConfiguration is present on classpath // 2. Detect if SecurityConfiguration is present on classpath
if (isClassPresent("stirling.software.proprietary.security.configuration.SecurityConfiguration")) { if (isClassPresent(
log.info("security"); "stirling.software.proprietary.security.configuration.SecurityConfiguration")) {
return new String[] { "security" }; log.info("security");
return new String[] {"security"};
} else { } else {
log.info("default"); log.info("default");
return new String[] { "default" }; return new String[] {"default"};
} }
} }
private static boolean isClassPresent(String className) { private static boolean isClassPresent(String className) {
try { try {
Class.forName(className, false, SPDFApplication.class.getClassLoader()); Class.forName(className, false, SPDFApplication.class.getClassLoader());

View File

@ -25,9 +25,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
// Handler for external static resources // Handler for external static resources
registry.addResourceHandler("/**") registry.addResourceHandler("/**")
.addResourceLocations( .addResourceLocations(
"file:" + InstallationPathConfig.getStaticPath(), "file:" + InstallationPathConfig.getStaticPath(), "classpath:/static/");
"classpath:/static/"
);
registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/"); registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/"); registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
// .setCachePeriod(0); // Optional: disable caching // .setCachePeriod(0); // Optional: disable caching

View File

@ -69,7 +69,8 @@ public class EditTableOfContentsController {
} }
} }
private List<Map<String, Object>> extractBookmarkItems(PDDocument document, PDDocumentOutline outline) throws Exception { private List<Map<String, Object>> extractBookmarkItems(
PDDocument document, PDDocumentOutline outline) throws Exception {
List<Map<String, Object>> bookmarks = new ArrayList<>(); List<Map<String, Object>> bookmarks = new ArrayList<>();
PDOutlineItem current = outline.getFirstChild(); PDOutlineItem current = outline.getFirstChild();
@ -114,7 +115,8 @@ public class EditTableOfContentsController {
return bookmarks; return bookmarks;
} }
private Map<String, Object> processChild(PDDocument document, PDOutlineItem item) throws Exception { private Map<String, Object> processChild(PDDocument document, PDOutlineItem item)
throws Exception {
Map<String, Object> bookmark = new HashMap<>(); Map<String, Object> bookmark = new HashMap<>();
// Get bookmark title // Get bookmark title
@ -154,8 +156,8 @@ public class EditTableOfContentsController {
@Operation( @Operation(
summary = "Edit Table of Contents", summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.") description = "Add or edit bookmarks/table of contents in a PDF document.")
public ResponseEntity<byte[]> editTableOfContents(@ModelAttribute EditTableOfContentsRequest request) public ResponseEntity<byte[]> editTableOfContents(
throws Exception { @ModelAttribute EditTableOfContentsRequest request) throws Exception {
MultipartFile file = request.getFileInput(); MultipartFile file = request.getFileInput();
PDDocument document = null; PDDocument document = null;
@ -163,9 +165,9 @@ public class EditTableOfContentsController {
document = pdfDocumentFactory.load(file); document = pdfDocumentFactory.load(file);
// Parse the bookmark data from JSON // Parse the bookmark data from JSON
List<BookmarkItem> bookmarks = objectMapper.readValue( List<BookmarkItem> bookmarks =
request.getBookmarkData(), objectMapper.readValue(
new TypeReference<List<BookmarkItem>>() {}); request.getBookmarkData(), new TypeReference<List<BookmarkItem>>() {});
// Create a new document outline // Create a new document outline
PDDocumentOutline outline = new PDDocumentOutline(); PDDocumentOutline outline = new PDDocumentOutline();
@ -189,7 +191,8 @@ public class EditTableOfContentsController {
} }
} }
private void addBookmarksToOutline(PDDocument document, PDDocumentOutline outline, List<BookmarkItem> bookmarks) { private void addBookmarksToOutline(
PDDocument document, PDDocumentOutline outline, List<BookmarkItem> bookmarks) {
for (BookmarkItem bookmark : bookmarks) { for (BookmarkItem bookmark : bookmarks) {
PDOutlineItem item = createOutlineItem(document, bookmark); PDOutlineItem item = createOutlineItem(document, bookmark);
outline.addLast(item); outline.addLast(item);
@ -200,7 +203,8 @@ public class EditTableOfContentsController {
} }
} }
private void addChildBookmarks(PDDocument document, PDOutlineItem parent, List<BookmarkItem> children) { private void addChildBookmarks(
PDDocument document, PDOutlineItem parent, List<BookmarkItem> children) {
for (BookmarkItem child : children) { for (BookmarkItem child : children) {
PDOutlineItem item = createOutlineItem(document, child); PDOutlineItem item = createOutlineItem(document, child);
parent.addLast(item); parent.addLast(item);

View File

@ -27,9 +27,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.PDFWithPageNums;
@RestController @RestController
@RequestMapping("/api/v1/general") @RequestMapping("/api/v1/general")

View File

@ -31,11 +31,11 @@ import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
import stirling.software.common.model.PdfMetadata; import stirling.software.common.model.PdfMetadata;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest;
@RestController @RestController
@RequestMapping("/api/v1/general") @RequestMapping("/api/v1/general")

View File

@ -31,9 +31,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
@RestController @RestController
@RequestMapping("/api/v1/general") @RequestMapping("/api/v1/general")

View File

@ -16,8 +16,10 @@ import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames; import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.converters.EmlToPdfRequest; import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
@ -39,9 +41,9 @@ public class ConvertEmlToPDF {
summary = "Convert EML to PDF", summary = "Convert EML to PDF",
description = description =
"This endpoint converts EML (email) files to PDF format with extensive" "This endpoint converts EML (email) files to PDF format with extensive"
+ " customization options. Features include font settings, image constraints, display modes, attachment handling," + " customization options. Features include font settings, image constraints, display modes, attachment handling,"
+ " and HTML debug output. Input: EML file, Output: PDF" + " and HTML debug output. Input: EML file, Output: PDF"
+ " or HTML file. Type: SISO") + " or HTML file. Type: SISO")
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) { public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput(); MultipartFile inputFile = request.getFileInput();
@ -94,7 +96,8 @@ public class ConvertEmlToPDF {
try { try {
byte[] pdfBytes = byte[] pdfBytes =
EmlToPdf.convertEmlToPdf( EmlToPdf.convertEmlToPdf(
runtimePathConfig.getWeasyPrintPath(), // Use configured WeasyPrint path runtimePathConfig
.getWeasyPrintPath(), // Use configured WeasyPrint path
request, request,
fileBytes, fileBytes,
originalFilename, originalFilename,
@ -119,12 +122,20 @@ public class ConvertEmlToPDF {
.body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8)); .body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
String errorMessage = buildErrorMessage(e, originalFilename); String errorMessage = buildErrorMessage(e, originalFilename);
log.error("EML to PDF conversion failed for {}: {}", originalFilename, errorMessage, e); log.error(
"EML to PDF conversion failed for {}: {}",
originalFilename,
errorMessage,
e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorMessage.getBytes(StandardCharsets.UTF_8)); .body(errorMessage.getBytes(StandardCharsets.UTF_8));
} catch (RuntimeException e) { } catch (RuntimeException e) {
String errorMessage = buildErrorMessage(e, originalFilename); String errorMessage = buildErrorMessage(e, originalFilename);
log.error("EML to PDF conversion failed for {}: {}", originalFilename, errorMessage, e); log.error(
"EML to PDF conversion failed for {}: {}",
originalFilename,
errorMessage,
e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorMessage.getBytes(StandardCharsets.UTF_8)); .body(errorMessage.getBytes(StandardCharsets.UTF_8));
} }

View File

@ -23,9 +23,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FileToPdf; import stirling.software.common.util.FileToPdf;
import stirling.software.common.util.WebResponseUtils; import stirling.software.common.util.WebResponseUtils;

View File

@ -36,8 +36,8 @@ import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.model.PipelineOperation; import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult; import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService; import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.service.PostHogService;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PostHogService;
import stirling.software.common.util.FileMonitor; import stirling.software.common.util.FileMonitor;
@Service @Service

View File

@ -184,7 +184,8 @@ public class RedactController {
String pageNumbersInput = request.getPageNumbers(); String pageNumbersInput = request.getPageNumbers();
String[] parsedPageNumbers = String[] parsedPageNumbers =
pageNumbersInput != null ? pageNumbersInput.split(",") : new String[0]; pageNumbersInput != null ? pageNumbersInput.split(",") : new String[0];
List<Integer> pageNumbers = GeneralUtils.parsePageList(parsedPageNumbers, pagesCount, false); List<Integer> pageNumbers =
GeneralUtils.parsePageList(parsedPageNumbers, pagesCount, false);
Collections.sort(pageNumbers); Collections.sort(pageNumbers);
return pageNumbers; return pageNumbers;
} }

View File

@ -22,8 +22,8 @@ import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.SPDF.model.Dependency; import stirling.software.SPDF.model.Dependency;
import stirling.software.common.model.ApplicationProperties;
@Slf4j @Slf4j
@Controller @Controller
@ -48,9 +48,7 @@ public class HomeWebController {
InputStream is = resource.getInputStream(); InputStream is = resource.getInputStream();
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> data = Map<String, List<Dependency>> data = mapper.readValue(json, new TypeReference<>() {});
mapper.readValue(json, new TypeReference<>() {
});
model.addAttribute("dependencies", data.get("dependencies")); model.addAttribute("dependencies", data.get("dependencies"));
} catch (IOException e) { } catch (IOException e) {
log.error("exception", e); log.error("exception", e);

View File

@ -4,15 +4,21 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data
@EqualsAndHashCode(callSuper = false) @EqualsAndHashCode(callSuper = false)
public class EditTableOfContentsRequest extends PDFFile { public class EditTableOfContentsRequest extends PDFFile {
@Schema(description = "Bookmark structure in JSON format", example = "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2}]}]") @Schema(
description = "Bookmark structure in JSON format",
example =
"[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2}]}]")
private String bookmarkData; private String bookmarkData;
@Schema(description = "Whether to replace existing bookmarks or append to them", example = "true") @Schema(
description = "Whether to replace existing bookmarks or append to them",
example = "true")
private Boolean replaceExisting; private Boolean replaceExisting;
} }

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.SPDF.model.api.PDFWithPageNums; import stirling.software.SPDF.model.api.PDFWithPageNums;
@Data @Data
@ -11,31 +12,31 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
public class ConvertToImageRequest extends PDFWithPageNums { public class ConvertToImageRequest extends PDFWithPageNums {
@Schema( @Schema(
description = "The output image format", description = "The output image format",
defaultValue = "png", defaultValue = "png",
allowableValues = {"png", "jpeg", "jpg", "gif", "webp"}, allowableValues = {"png", "jpeg", "jpg", "gif", "webp"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String imageFormat; private String imageFormat;
@Schema( @Schema(
description = description =
"Choose between a single image containing all pages or separate images for each" "Choose between a single image containing all pages or separate images for each"
+ " page", + " page",
defaultValue = "multiple", defaultValue = "multiple",
allowableValues = {"single", "multiple"}, allowableValues = {"single", "multiple"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String singleOrMultiple; private String singleOrMultiple;
@Schema( @Schema(
description = "The color type of the output image(s)", description = "The color type of the output image(s)",
defaultValue = "color", defaultValue = "color",
allowableValues = {"color", "greyscale", "blackwhite"}, allowableValues = {"color", "greyscale", "blackwhite"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String colorType; private String colorType;
@Schema( @Schema(
description = "The DPI (dots per inch) for the output image(s)", description = "The DPI (dots per inch) for the output image(s)",
defaultValue = "300", defaultValue = "300",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Integer dpi; private Integer dpi;
} }

View File

@ -12,8 +12,8 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
public class ContainsTextRequest extends PDFWithPageNums { public class ContainsTextRequest extends PDFWithPageNums {
@Schema( @Schema(
description = "The text to check for", description = "The text to check for",
defaultValue = "text", defaultValue = "text",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String text; private String text;
} }

View File

@ -12,8 +12,8 @@ import stirling.software.SPDF.model.api.PDFComparison;
public class FileSizeRequest extends PDFComparison { public class FileSizeRequest extends PDFComparison {
@Schema( @Schema(
description = "Size of the file in bytes", description = "Size of the file in bytes",
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "0") defaultValue = "0")
private long fileSize; private long fileSize;
} }

View File

@ -12,8 +12,8 @@ import stirling.software.SPDF.model.api.PDFComparison;
public class PageRotationRequest extends PDFComparison { public class PageRotationRequest extends PDFComparison {
@Schema( @Schema(
description = "Rotation in degrees", description = "Rotation in degrees",
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "0") defaultValue = "0")
private int rotation; private int rotation;
} }

View File

@ -12,9 +12,9 @@ import stirling.software.SPDF.model.api.PDFComparison;
public class PageSizeRequest extends PDFComparison { public class PageSizeRequest extends PDFComparison {
@Schema( @Schema(
description = "Standard Page Size", description = "Standard Page Size",
allowableValues = {"A0", "A1", "A2", "A3", "A4", "A5", "A6", "LETTER", "LEGAL"}, allowableValues = {"A0", "A1", "A2", "A3", "A4", "A5", "A6", "LETTER", "LEGAL"},
defaultValue = "A4", defaultValue = "A4",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String standardPageSize; private String standardPageSize;
} }

View File

@ -14,33 +14,33 @@ import stirling.software.common.model.api.PDFFile;
public class OverlayPdfsRequest extends PDFFile { public class OverlayPdfsRequest extends PDFFile {
@Schema( @Schema(
description = description =
"An array of PDF files to be used as overlays on the base PDF. The order in" "An array of PDF files to be used as overlays on the base PDF. The order in"
+ " these files is applied based on the selected mode.", + " these files is applied based on the selected mode.",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile[] overlayFiles; private MultipartFile[] overlayFiles;
@Schema( @Schema(
description = description =
"The mode of overlaying: 'SequentialOverlay' for sequential application," "The mode of overlaying: 'SequentialOverlay' for sequential application,"
+ " 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay'" + " 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay'"
+ " for fixed repetition based on provided counts", + " for fixed repetition based on provided counts",
allowableValues = {"SequentialOverlay", "InterleavedOverlay", "FixedRepeatOverlay"}, allowableValues = {"SequentialOverlay", "InterleavedOverlay", "FixedRepeatOverlay"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String overlayMode; private String overlayMode;
@Schema( @Schema(
description = description =
"An array of integers specifying the number of times each corresponding overlay" "An array of integers specifying the number of times each corresponding overlay"
+ " file should be applied in the 'FixedRepeatOverlay' mode. This should" + " file should be applied in the 'FixedRepeatOverlay' mode. This should"
+ " match the length of the overlayFiles array.", + " match the length of the overlayFiles array.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private int[] counts; private int[] counts;
@Schema( @Schema(
description = "Overlay position 0 is Foregound, 1 is Background", description = "Overlay position 0 is Foregound, 1 is Background",
allowableValues = {"0", "1"}, allowableValues = {"0", "1"},
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
type = "number") type = "number")
private int overlayPosition; private int overlayPosition;
} }

View File

@ -14,9 +14,9 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
public class AddStampRequest extends PDFWithPageNums { public class AddStampRequest extends PDFWithPageNums {
@Schema( @Schema(
description = "The stamp type (text or image)", description = "The stamp type (text or image)",
allowableValues = {"text", "image"}, allowableValues = {"text", "image"},
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String stampType; private String stampType;
@Schema(description = "The stamp text", defaultValue = "Stirling Software") @Schema(description = "The stamp text", defaultValue = "Stirling Software")
@ -26,60 +26,60 @@ public class AddStampRequest extends PDFWithPageNums {
private MultipartFile stampImage; private MultipartFile stampImage;
@Schema( @Schema(
description = "The selected alphabet of the stamp text", description = "The selected alphabet of the stamp text",
allowableValues = {"roman", "arabic", "japanese", "korean", "chinese"}, allowableValues = {"roman", "arabic", "japanese", "korean", "chinese"},
defaultValue = "roman") defaultValue = "roman")
private String alphabet = "roman"; private String alphabet = "roman";
@Schema( @Schema(
description = "The font size of the stamp text and image", description = "The font size of the stamp text and image",
defaultValue = "30", defaultValue = "30",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float fontSize; private float fontSize;
@Schema( @Schema(
description = "The rotation of the stamp in degrees", description = "The rotation of the stamp in degrees",
defaultValue = "0", defaultValue = "0",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float rotation; private float rotation;
@Schema( @Schema(
description = "The opacity of the stamp (0.0 - 1.0)", description = "The opacity of the stamp (0.0 - 1.0)",
defaultValue = "0.5", defaultValue = "0.5",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float opacity; private float opacity;
@Schema( @Schema(
description = description =
"Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center," "Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center,"
+ " 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right," + " 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right,"
+ " 7: top-left, 8: top-center, 9: top-right)", + " 7: top-left, 8: top-center, 9: top-right)",
allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"}, allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"},
defaultValue = "5", defaultValue = "5",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private int position; private int position;
@Schema( @Schema(
description = description =
"Override X coordinate for stamp placement. If set, it will override the" "Override X coordinate for stamp placement. If set, it will override the"
+ " position-based calculation. Negative value means no override.", + " position-based calculation. Negative value means no override.",
defaultValue = "-1", defaultValue = "-1",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float overrideX; // Default to -1 indicating no override private float overrideX; // Default to -1 indicating no override
@Schema( @Schema(
description = description =
"Override Y coordinate for stamp placement. If set, it will override the" "Override Y coordinate for stamp placement. If set, it will override the"
+ " position-based calculation. Negative value means no override.", + " position-based calculation. Negative value means no override.",
defaultValue = "-1", defaultValue = "-1",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float overrideY; // Default to -1 indicating no override private float overrideY; // Default to -1 indicating no override
@Schema( @Schema(
description = "Specifies the margin size for the stamp.", description = "Specifies the margin size for the stamp.",
allowableValues = {"small", "medium", "large", "x-large"}, allowableValues = {"small", "medium", "large", "x-large"},
defaultValue = "medium", defaultValue = "medium",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String customMargin; private String customMargin;
@Schema(description = "The color of the stamp text", defaultValue = "#d3d3d3") @Schema(description = "The color of the stamp text", defaultValue = "#d3d3d3")

View File

@ -14,71 +14,71 @@ import stirling.software.common.model.api.PDFFile;
public class MetadataRequest extends PDFFile { public class MetadataRequest extends PDFFile {
@Schema( @Schema(
description = "Delete all metadata if set to true", description = "Delete all metadata if set to true",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean deleteAll; private Boolean deleteAll;
@Schema( @Schema(
description = "The author of the document", description = "The author of the document",
defaultValue = "author", defaultValue = "author",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String author; private String author;
@Schema( @Schema(
description = "The creation date of the document (format: yyyy/MM/dd HH:mm:ss)", description = "The creation date of the document (format: yyyy/MM/dd HH:mm:ss)",
pattern = "yyyy/MM/dd HH:mm:ss", pattern = "yyyy/MM/dd HH:mm:ss",
defaultValue = "2023/10/01 12:00:00", defaultValue = "2023/10/01 12:00:00",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String creationDate; private String creationDate;
@Schema( @Schema(
description = "The creator of the document", description = "The creator of the document",
defaultValue = "creator", defaultValue = "creator",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String creator; private String creator;
@Schema( @Schema(
description = "The keywords for the document", description = "The keywords for the document",
defaultValue = "keywords", defaultValue = "keywords",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String keywords; private String keywords;
@Schema( @Schema(
description = "The modification date of the document (format: yyyy/MM/dd HH:mm:ss)", description = "The modification date of the document (format: yyyy/MM/dd HH:mm:ss)",
pattern = "yyyy/MM/dd HH:mm:ss", pattern = "yyyy/MM/dd HH:mm:ss",
defaultValue = "2023/10/01 12:00:00", defaultValue = "2023/10/01 12:00:00",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String modificationDate; private String modificationDate;
@Schema( @Schema(
description = "The producer of the document", description = "The producer of the document",
defaultValue = "producer", defaultValue = "producer",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String producer; private String producer;
@Schema( @Schema(
description = "The subject of the document", description = "The subject of the document",
defaultValue = "subject", defaultValue = "subject",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String subject; private String subject;
@Schema( @Schema(
description = "The title of the document", description = "The title of the document",
defaultValue = "title", defaultValue = "title",
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String title; private String title;
@Schema( @Schema(
description = "The trapped status of the document", description = "The trapped status of the document",
defaultValue = "False", defaultValue = "False",
allowableValues = {"True", "False", "Unknown"}, allowableValues = {"True", "False", "Unknown"},
requiredMode = Schema.RequiredMode.NOT_REQUIRED) requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String trapped; private String trapped;
@Schema( @Schema(
description = description =
"Map list of key and value of custom parameters. Note these must start with" "Map list of key and value of custom parameters. Note these must start with"
+ " customKey and customValue if they are non-standard") + " customKey and customValue if they are non-standard")
private Map<String, String> allRequestParams; private Map<String, String> allRequestParams;
} }

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data

View File

@ -12,24 +12,24 @@ import stirling.software.common.model.api.PDFFile;
public class AddPasswordRequest extends PDFFile { public class AddPasswordRequest extends PDFFile {
@Schema( @Schema(
description = description =
"The owner password to be added to the PDF file (Restricts what can be done" "The owner password to be added to the PDF file (Restricts what can be done"
+ " with the document once it is opened)", + " with the document once it is opened)",
format = "password") format = "password")
private String ownerPassword; private String ownerPassword;
@Schema( @Schema(
description = description =
"The password to be added to the PDF file (Restricts the opening of the" "The password to be added to the PDF file (Restricts the opening of the"
+ " document itself.)", + " document itself.)",
format = "password") format = "password")
private String password; private String password;
@Schema( @Schema(
description = "The length of the encryption key", description = "The length of the encryption key",
allowableValues = {"40", "128", "256"}, allowableValues = {"40", "128", "256"},
defaultValue = "256", defaultValue = "256",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private int keyLength = 256; private int keyLength = 256;
@Schema(description = "Whether document assembly is prevented", defaultValue = "false") @Schema(description = "Whether document assembly is prevented", defaultValue = "false")
@ -39,8 +39,8 @@ public class AddPasswordRequest extends PDFFile {
private Boolean preventExtractContent; private Boolean preventExtractContent;
@Schema( @Schema(
description = "Whether content extraction for accessibility is prevented", description = "Whether content extraction for accessibility is prevented",
defaultValue = "false") defaultValue = "false")
private Boolean preventExtractForAccessibility; private Boolean preventExtractForAccessibility;
@Schema(description = "Whether form filling is prevented", defaultValue = "false") @Schema(description = "Whether form filling is prevented", defaultValue = "false")
@ -50,8 +50,8 @@ public class AddPasswordRequest extends PDFFile {
private Boolean preventModify; private Boolean preventModify;
@Schema( @Schema(
description = "Whether modification of annotations is prevented", description = "Whether modification of annotations is prevented",
defaultValue = "false") defaultValue = "false")
private Boolean preventModifyAnnotations; private Boolean preventModifyAnnotations;
@Schema(description = "Whether printing of the document is prevented", defaultValue = "false") @Schema(description = "Whether printing of the document is prevented", defaultValue = "false")

View File

@ -14,19 +14,19 @@ import stirling.software.common.model.api.security.RedactionArea;
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ManualRedactPdfRequest extends PDFWithPageNums { public class ManualRedactPdfRequest extends PDFWithPageNums {
@Schema( @Schema(
description = "A list of areas that should be redacted", description = "A list of areas that should be redacted",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private List<RedactionArea> redactions; private List<RedactionArea> redactions;
@Schema( @Schema(
description = "Convert the redacted PDF to an image", description = "Convert the redacted PDF to an image",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean convertPDFToImage; private Boolean convertPDFToImage;
@Schema( @Schema(
description = "The color used to fully redact certain pages", description = "The color used to fully redact certain pages",
defaultValue = "#000000", defaultValue = "#000000",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String pageRedactionColor; private String pageRedactionColor;
} }

View File

@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.api.PDFFile;
@Data @Data
@ -11,38 +12,38 @@ import stirling.software.common.model.api.PDFFile;
public class RedactPdfRequest extends PDFFile { public class RedactPdfRequest extends PDFFile {
@Schema( @Schema(
description = "List of text to redact from the PDF", description = "List of text to redact from the PDF",
defaultValue = "text,text2", defaultValue = "text,text2",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String listOfText; private String listOfText;
@Schema( @Schema(
description = "Whether to use regex for the listOfText", description = "Whether to use regex for the listOfText",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean useRegex; private Boolean useRegex;
@Schema( @Schema(
description = "Whether to use whole word search", description = "Whether to use whole word search",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean wholeWordSearch; private Boolean wholeWordSearch;
@Schema( @Schema(
description = "The color for redaction", description = "The color for redaction",
defaultValue = "#000000", defaultValue = "#000000",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private String redactColor; private String redactColor;
@Schema( @Schema(
description = "Custom padding for redaction", description = "Custom padding for redaction",
type = "number", type = "number",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private float customPadding; private float customPadding;
@Schema( @Schema(
description = "Convert the redacted PDF to an image", description = "Convert the redacted PDF to an image",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean convertPDFToImage; private Boolean convertPDFToImage;
} }

View File

@ -12,38 +12,38 @@ import stirling.software.common.model.api.PDFFile;
public class SanitizePdfRequest extends PDFFile { public class SanitizePdfRequest extends PDFFile {
@Schema( @Schema(
description = "Remove JavaScript actions from the PDF", description = "Remove JavaScript actions from the PDF",
defaultValue = "true", defaultValue = "true",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeJavaScript; private Boolean removeJavaScript;
@Schema( @Schema(
description = "Remove embedded files from the PDF", description = "Remove embedded files from the PDF",
defaultValue = "true", defaultValue = "true",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeEmbeddedFiles; private Boolean removeEmbeddedFiles;
@Schema( @Schema(
description = "Remove XMP metadata from the PDF", description = "Remove XMP metadata from the PDF",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeXMPMetadata; private Boolean removeXMPMetadata;
@Schema( @Schema(
description = "Remove document info metadata from the PDF", description = "Remove document info metadata from the PDF",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeMetadata; private Boolean removeMetadata;
@Schema( @Schema(
description = "Remove links from the PDF", description = "Remove links from the PDF",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeLinks; private Boolean removeLinks;
@Schema( @Schema(
description = "Remove fonts from the PDF", description = "Remove fonts from the PDF",
defaultValue = "false", defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED) requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean removeFonts; private Boolean removeFonts;
} }

View File

@ -14,8 +14,8 @@ import io.micrometer.core.instrument.search.Search;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import stirling.software.common.service.PostHogService;
import stirling.software.SPDF.config.EndpointInspector; import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.common.service.PostHogService;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor

View File

@ -0,0 +1,382 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.SPDF.controller.api.EditTableOfContentsController.BookmarkItem;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class EditTableOfContentsControllerTest {
@Mock
private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock
private ObjectMapper objectMapper;
@InjectMocks
private EditTableOfContentsController editTableOfContentsController;
private MockMultipartFile mockFile;
private PDDocument mockDocument;
private PDDocumentCatalog mockCatalog;
private PDPageTree mockPages;
private PDPage mockPage1;
private PDPage mockPage2;
private PDDocumentOutline mockOutline;
private PDOutlineItem mockOutlineItem;
@BeforeEach
void setUp() {
mockFile = new MockMultipartFile("file", "test.pdf", "application/pdf", "PDF content".getBytes());
mockDocument = mock(PDDocument.class);
mockCatalog = mock(PDDocumentCatalog.class);
mockPages = mock(PDPageTree.class);
mockPage1 = mock(PDPage.class);
mockPage2 = mock(PDPage.class);
mockOutline = mock(PDDocumentOutline.class);
mockOutlineItem = mock(PDOutlineItem.class);
}
@Test
void testExtractBookmarks_WithExistingBookmarks_Success() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline);
when(mockOutline.getFirstChild()).thenReturn(mockOutlineItem);
when(mockOutlineItem.getTitle()).thenReturn("Chapter 1");
when(mockOutlineItem.findDestinationPage(mockDocument)).thenReturn(mockPage1);
when(mockDocument.getPages()).thenReturn(mockPages);
when(mockPages.indexOf(mockPage1)).thenReturn(0);
when(mockOutlineItem.getFirstChild()).thenReturn(null);
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(result);
assertEquals(1, result.size());
Map<String, Object> bookmark = result.get(0);
assertEquals("Chapter 1", bookmark.get("title"));
assertEquals(1, bookmark.get("pageNumber")); // 1-based
assertInstanceOf(List.class, bookmark.get("children"));
verify(mockDocument).close();
}
@Test
void testExtractBookmarks_NoOutline_ReturnsEmptyList() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockCatalog.getDocumentOutline()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(result);
assertTrue(result.isEmpty());
verify(mockDocument).close();
}
@Test
void testExtractBookmarks_WithNestedBookmarks_Success() throws Exception {
// Given
PDOutlineItem childItem = mock(PDOutlineItem.class);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline);
when(mockOutline.getFirstChild()).thenReturn(mockOutlineItem);
// Parent bookmark
when(mockOutlineItem.getTitle()).thenReturn("Chapter 1");
when(mockOutlineItem.findDestinationPage(mockDocument)).thenReturn(mockPage1);
when(mockDocument.getPages()).thenReturn(mockPages);
when(mockPages.indexOf(mockPage1)).thenReturn(0);
when(mockOutlineItem.getFirstChild()).thenReturn(childItem);
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// Child bookmark
when(childItem.getTitle()).thenReturn("Section 1.1");
when(childItem.findDestinationPage(mockDocument)).thenReturn(mockPage2);
when(mockPages.indexOf(mockPage2)).thenReturn(1);
when(childItem.getFirstChild()).thenReturn(null);
when(childItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(result);
assertEquals(1, result.size());
Map<String, Object> parentBookmark = result.get(0);
assertEquals("Chapter 1", parentBookmark.get("title"));
assertEquals(1, parentBookmark.get("pageNumber"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) parentBookmark.get("children");
assertEquals(1, children.size());
Map<String, Object> childBookmark = children.get(0);
assertEquals("Section 1.1", childBookmark.get("title"));
assertEquals(2, childBookmark.get("pageNumber"));
verify(mockDocument).close();
}
@Test
void testExtractBookmarks_PageNotFound_UsesPageOne() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline);
when(mockOutline.getFirstChild()).thenReturn(mockOutlineItem);
when(mockOutlineItem.getTitle()).thenReturn("Chapter 1");
when(mockOutlineItem.findDestinationPage(mockDocument)).thenReturn(null); // Page not found
when(mockOutlineItem.getFirstChild()).thenReturn(null);
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(result);
assertEquals(1, result.size());
Map<String, Object> bookmark = result.get(0);
assertEquals("Chapter 1", bookmark.get("title"));
assertEquals(1, bookmark.get("pageNumber")); // Default to page 1
verify(mockDocument).close();
}
@Test
void testEditTableOfContents_Success() throws Exception {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
request.setBookmarkData("[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[]}]");
request.setReplaceExisting(true);
List<BookmarkItem> bookmarks = new ArrayList<>();
BookmarkItem bookmark = new BookmarkItem();
bookmark.setTitle("Chapter 1");
bookmark.setPageNumber(1);
bookmark.setChildren(new ArrayList<>());
bookmarks.add(bookmark);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1);
// Mock saving behavior
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
// Then
assertNotNull(result);
assertNotNull(result.getBody());
ArgumentCaptor<PDDocumentOutline> outlineCaptor = ArgumentCaptor.forClass(PDDocumentOutline.class);
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
assertNotNull(capturedOutline);
verify(mockDocument).close();
}
@Test
void testEditTableOfContents_WithNestedBookmarks_Success() throws Exception {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
String bookmarkJson = "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2,\"children\":[]}]}]";
request.setBookmarkData(bookmarkJson);
List<BookmarkItem> bookmarks = new ArrayList<>();
BookmarkItem parentBookmark = new BookmarkItem();
parentBookmark.setTitle("Chapter 1");
parentBookmark.setPageNumber(1);
BookmarkItem childBookmark = new BookmarkItem();
childBookmark.setTitle("Section 1.1");
childBookmark.setPageNumber(2);
childBookmark.setChildren(new ArrayList<>());
List<BookmarkItem> children = new ArrayList<>();
children.add(childBookmark);
parentBookmark.setChildren(children);
bookmarks.add(parentBookmark);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(bookmarkJson), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1);
when(mockDocument.getPage(1)).thenReturn(mockPage2);
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
// Then
assertNotNull(result);
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(mockDocument).close();
}
@Test
void testEditTableOfContents_PageNumberBounds_ClampsValues() throws Exception {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
request.setBookmarkData("[{\"title\":\"Chapter 1\",\"pageNumber\":-5,\"children\":[]},{\"title\":\"Chapter 2\",\"pageNumber\":100,\"children\":[]}]");
List<BookmarkItem> bookmarks = new ArrayList<>();
BookmarkItem bookmark1 = new BookmarkItem();
bookmark1.setTitle("Chapter 1");
bookmark1.setPageNumber(-5); // Negative page number
bookmark1.setChildren(new ArrayList<>());
BookmarkItem bookmark2 = new BookmarkItem();
bookmark2.setTitle("Chapter 2");
bookmark2.setPageNumber(100); // Page number exceeds document pages
bookmark2.setChildren(new ArrayList<>());
bookmarks.add(bookmark1);
bookmarks.add(bookmark2);
when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument);
when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class))).thenReturn(bookmarks);
when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(0)).thenReturn(mockPage1); // For negative page number
when(mockDocument.getPage(4)).thenReturn(mockPage2); // For page number exceeding bounds
doAnswer(invocation -> {
ByteArrayOutputStream baos = invocation.getArgument(0);
baos.write("mocked pdf content".getBytes());
return null;
}).when(mockDocument).save(any(ByteArrayOutputStream.class));
// When
ResponseEntity<byte[]> result = editTableOfContentsController.editTableOfContents(request);
// Then
assertNotNull(result);
verify(mockDocument).getPage(0); // Clamped to first page
verify(mockDocument).getPage(4); // Clamped to last page
verify(mockDocument).close();
}
@Test
void testCreateOutlineItem_ValidPageNumber_Success() throws Exception {
// Given
BookmarkItem bookmark = new BookmarkItem();
bookmark.setTitle("Test Chapter");
bookmark.setPageNumber(3);
when(mockDocument.getNumberOfPages()).thenReturn(5);
when(mockDocument.getPage(2)).thenReturn(mockPage1); // 0-indexed
// When
Method createOutlineItemMethod = EditTableOfContentsController.class.getDeclaredMethod("createOutlineItem", PDDocument.class, BookmarkItem.class);
createOutlineItemMethod.setAccessible(true);
PDOutlineItem result = (PDOutlineItem) createOutlineItemMethod.invoke(editTableOfContentsController, mockDocument, bookmark);
// Then
assertNotNull(result);
verify(mockDocument).getPage(2);
}
@Test
void testBookmarkItem_GettersAndSetters() {
// Given
BookmarkItem bookmark = new BookmarkItem();
List<BookmarkItem> children = new ArrayList<>();
// When
bookmark.setTitle("Test Title");
bookmark.setPageNumber(5);
bookmark.setChildren(children);
// Then
assertEquals("Test Title", bookmark.getTitle());
assertEquals(5, bookmark.getPageNumber());
assertEquals(children, bookmark.getChildren());
}
@Test
void testEditTableOfContents_IOExceptionDuringLoad_ThrowsException() throws Exception {
// Given
EditTableOfContentsRequest request = new EditTableOfContentsRequest();
request.setFileInput(mockFile);
when(pdfDocumentFactory.load(mockFile)).thenThrow(new RuntimeException("Failed to load PDF"));
// When & Then
assertThrows(RuntimeException.class, () -> editTableOfContentsController.editTableOfContents(request));
}
@Test
void testExtractBookmarks_IOExceptionDuringLoad_ThrowsException() throws Exception {
// Given
when(pdfDocumentFactory.load(mockFile)).thenThrow(new RuntimeException("Failed to load PDF"));
// When & Then
assertThrows(RuntimeException.class, () -> editTableOfContentsController.extractBookmarks(mockFile));
}
}

View File

@ -0,0 +1,279 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
class MergeControllerTest {
@Mock
private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks
private MergeController mergeController;
private MockMultipartFile mockFile1;
private MockMultipartFile mockFile2;
private MockMultipartFile mockFile3;
private PDDocument mockDocument;
private PDDocument mockMergedDocument;
private PDDocumentCatalog mockCatalog;
private PDPageTree mockPages;
private PDPage mockPage1;
private PDPage mockPage2;
@BeforeEach
void setUp() {
mockFile1 = new MockMultipartFile("file1", "document1.pdf", "application/pdf", "PDF content 1".getBytes());
mockFile2 = new MockMultipartFile("file2", "document2.pdf", "application/pdf", "PDF content 2".getBytes());
mockFile3 = new MockMultipartFile("file3", "chapter3.pdf", "application/pdf", "PDF content 3".getBytes());
mockDocument = mock(PDDocument.class);
mockMergedDocument = mock(PDDocument.class);
mockCatalog = mock(PDDocumentCatalog.class);
mockPages = mock(PDPageTree.class);
mockPage1 = mock(PDPage.class);
mockPage2 = mock(PDPage.class);
}
@Test
void testAddTableOfContents_WithMultipleFiles_Success() throws Exception {
// Given
MultipartFile[] files = {mockFile1, mockFile2, mockFile3};
// Mock the merged document setup
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(6);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
when(mockMergedDocument.getPage(2)).thenReturn(mockPage2);
when(mockMergedDocument.getPage(4)).thenReturn(mockPage1);
// Mock individual document loading for page count
PDDocument doc1 = mock(PDDocument.class);
PDDocument doc2 = mock(PDDocument.class);
PDDocument doc3 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(pdfDocumentFactory.load(mockFile2)).thenReturn(doc2);
when(pdfDocumentFactory.load(mockFile3)).thenReturn(doc3);
when(doc1.getNumberOfPages()).thenReturn(2);
when(doc2.getNumberOfPages()).thenReturn(2);
when(doc3.getNumberOfPages()).thenReturn(2);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
ArgumentCaptor<PDDocumentOutline> outlineCaptor = ArgumentCaptor.forClass(PDDocumentOutline.class);
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
assertNotNull(capturedOutline);
// Verify that documents were loaded for page count
verify(pdfDocumentFactory).load(mockFile1);
verify(pdfDocumentFactory).load(mockFile2);
verify(pdfDocumentFactory).load(mockFile3);
// Verify document closing
verify(doc1).close();
verify(doc2).close();
verify(doc3).close();
}
@Test
void testAddTableOfContents_WithSingleFile_Success() throws Exception {
// Given
MultipartFile[] files = {mockFile1};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(3);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(3);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(pdfDocumentFactory).load(mockFile1);
verify(doc1).close();
}
@Test
void testAddTableOfContents_WithEmptyArray_Success() throws Exception {
// Given
MultipartFile[] files = {};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockMergedDocument).getDocumentCatalog();
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_WithIOException_HandlesGracefully() throws Exception {
// Given
MultipartFile[] files = {mockFile1, mockFile2};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(4);
when(mockMergedDocument.getPage(anyInt())).thenReturn(mockPage1); // Use anyInt() to avoid stubbing conflicts
// First document loads successfully
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(2);
// Second document throws IOException
when(pdfDocumentFactory.load(mockFile2)).thenThrow(new IOException("Failed to load document"));
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(() ->
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files)
);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(pdfDocumentFactory).load(mockFile1);
verify(pdfDocumentFactory).load(mockFile2);
verify(doc1).close();
}
@Test
void testAddTableOfContents_FilenameWithoutExtension_UsesFullName() throws Exception {
// Given
MockMultipartFile fileWithoutExtension = new MockMultipartFile("file", "document_no_ext", "application/pdf", "PDF content".getBytes());
MultipartFile[] files = {fileWithoutExtension};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(1);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
PDDocument doc = mock(PDDocument.class);
when(pdfDocumentFactory.load(fileWithoutExtension)).thenReturn(doc);
when(doc.getNumberOfPages()).thenReturn(1);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(doc).close();
}
@Test
void testAddTableOfContents_PageIndexExceedsDocumentPages_HandlesGracefully() throws Exception {
// Given
MultipartFile[] files = {mockFile1};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(0); // No pages in merged document
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(3);
// When
Method addTableOfContentsMethod = MergeController.class.getDeclaredMethod("addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(() ->
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files)
);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(mockMergedDocument, never()).getPage(anyInt());
verify(doc1).close();
}
@Test
void testMergeDocuments_Success() throws IOException {
// Given
PDDocument doc1 = mock(PDDocument.class);
PDDocument doc2 = mock(PDDocument.class);
List<PDDocument> documents = Arrays.asList(doc1, doc2);
PDPageTree pages1 = mock(PDPageTree.class);
PDPageTree pages2 = mock(PDPageTree.class);
PDPage page1 = mock(PDPage.class);
PDPage page2 = mock(PDPage.class);
when(pdfDocumentFactory.createNewDocument()).thenReturn(mockMergedDocument);
when(doc1.getPages()).thenReturn(pages1);
when(doc2.getPages()).thenReturn(pages2);
when(pages1.iterator()).thenReturn(Arrays.asList(page1).iterator());
when(pages2.iterator()).thenReturn(Arrays.asList(page2).iterator());
// When
PDDocument result = mergeController.mergeDocuments(documents);
// Then
assertNotNull(result);
assertEquals(mockMergedDocument, result);
verify(mockMergedDocument).addPage(page1);
verify(mockMergedDocument).addPage(page2);
}
@Test
void testMergeDocuments_EmptyList_ReturnsEmptyDocument() throws IOException {
// Given
List<PDDocument> documents = Arrays.asList();
when(pdfDocumentFactory.createNewDocument()).thenReturn(mockMergedDocument);
// When
PDDocument result = mergeController.mergeDocuments(documents);
// Then
assertNotNull(result);
assertEquals(mockMergedDocument, result);
verify(mockMergedDocument, never()).addPage(any(PDPage.class));
}
}