diff --git a/common/build.gradle b/common/build.gradle index 80d909bf6..9d175f6e5 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -51,5 +51,16 @@ dependencies { annotationProcessor "org.projectlombok:lombok:$lombokVersion" testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testImplementation 'org.junit.jupiter:junit-jupiter-engine' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.mockito:mockito-junit-jupiter' testRuntimeOnly 'org.mockito:mockito-inline:5.2.0' } + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } +} diff --git a/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java b/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java index 859621557..4e47f53b7 100644 --- a/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java +++ b/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java @@ -1,7 +1,5 @@ package stirling.software.common.aop; -import java.nio.file.Files; -import java.nio.file.Path; import java.io.IOException; import org.aspectj.lang.ProceedingJoinPoint; @@ -42,7 +40,7 @@ public class AutoJobAspect { for (int i = 0; i < args.length; i++) { Object arg = args[i]; - + if (arg instanceof PDFFile pdfFile) { // Case 1: fileId is provided but no fileInput if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) { @@ -54,24 +52,25 @@ public class AutoJobAspect { throw new RuntimeException( "Failed to resolve file by ID: " + pdfFile.getFileId(), e); } - } + } // Case 2: For async requests, we need to make a copy of the MultipartFile else if (isAsyncRequest && pdfFile.getFileInput() != null) { try { log.debug("Making persistent copy of uploaded file for async processing"); MultipartFile originalFile = pdfFile.getFileInput(); String fileId = fileStorage.storeFile(originalFile); - + // Store the fileId for later reference pdfFile.setFileId(fileId); - + // Replace the original MultipartFile with our persistent copy MultipartFile persistentFile = fileStorage.retrieveFile(fileId); pdfFile.setFileInput(persistentFile); - + log.debug("Created persistent file copy with fileId: {}", fileId); } catch (IOException e) { - throw new RuntimeException("Failed to create persistent copy of uploaded file", e); + throw new RuntimeException( + "Failed to create persistent copy of uploaded file", e); } } } diff --git a/common/src/main/java/stirling/software/common/controller/JobController.java b/common/src/main/java/stirling/software/common/controller/JobController.java new file mode 100644 index 000000000..a08d97575 --- /dev/null +++ b/common/src/main/java/stirling/software/common/controller/JobController.java @@ -0,0 +1,114 @@ +package stirling.software.common.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.JobStats; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.TaskManager; + +/** REST controller for job-related endpoints */ +@RestController +@RequiredArgsConstructor +@Slf4j +public class JobController { + + private final TaskManager taskManager; + private final FileStorage fileStorage; + + /** + * Get the status of a job + * + * @param jobId The job ID + * @return The job result + */ + @GetMapping("/api/v1/general/job/{jobId}") + public ResponseEntity getJobStatus(@PathVariable("jobId") String jobId) { + JobResult result = taskManager.getJobResult(jobId); + if (result == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(result); + } + + /** + * Get the result of a job + * + * @param jobId The job ID + * @return The job result + */ + @GetMapping("/api/v1/general/job/{jobId}/result") + public ResponseEntity getJobResult(@PathVariable("jobId") String jobId) { + JobResult result = taskManager.getJobResult(jobId); + if (result == null) { + return ResponseEntity.notFound().build(); + } + + if (!result.isComplete()) { + return ResponseEntity.badRequest().body("Job is not complete yet"); + } + + if (result.getError() != null) { + return ResponseEntity.badRequest().body("Job failed: " + result.getError()); + } + + if (result.getFileId() != null) { + try { + byte[] fileContent = fileStorage.retrieveBytes(result.getFileId()); + return ResponseEntity.ok() + .header("Content-Type", result.getContentType()) + .header( + "Content-Disposition", + "form-data; name=\"attachment\"; filename=\"" + + result.getOriginalFileName() + + "\"") + .body(fileContent); + } catch (Exception e) { + log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e); + return ResponseEntity.internalServerError() + .body("Error retrieving file: " + e.getMessage()); + } + } + + return ResponseEntity.ok(result.getResult()); + } + + /** + * Get statistics about jobs in the system + * + * @return Job statistics + */ + @GetMapping("/api/v1/general/job/stats") + public ResponseEntity getJobStats() { + JobStats stats = taskManager.getJobStats(); + return ResponseEntity.ok(stats); + } + + /** + * Manually trigger cleanup of old jobs + * + * @return A response indicating how many jobs were cleaned up + */ + @PostMapping("/api/v1/general/job/cleanup") + public ResponseEntity cleanupOldJobs() { + int beforeCount = taskManager.getJobStats().getTotalJobs(); + taskManager.cleanupOldJobs(); + int afterCount = taskManager.getJobStats().getTotalJobs(); + int removedCount = beforeCount - afterCount; + + return ResponseEntity.ok( + Map.of( + "message", "Cleanup complete", + "removedJobs", removedCount, + "remainingJobs", afterCount)); + } +} diff --git a/common/src/main/java/stirling/software/common/model/job/JobResult.java b/common/src/main/java/stirling/software/common/model/job/JobResult.java index 93c41ff4c..214e92c1a 100644 --- a/common/src/main/java/stirling/software/common/model/job/JobResult.java +++ b/common/src/main/java/stirling/software/common/model/job/JobResult.java @@ -7,64 +7,43 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -/** - * Represents the result of a job execution. - * Used by the TaskManager to store job results. - */ +/** Represents the result of a job execution. Used by the TaskManager to store job results. */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class JobResult { - - /** - * The job ID - */ + + /** The job ID */ private String jobId; - - /** - * Flag indicating if the job is complete - */ + + /** Flag indicating if the job is complete */ private boolean complete; - - /** - * Error message if the job failed - */ + + /** Error message if the job failed */ private String error; - - /** - * The file ID of the result file, if applicable - */ + + /** The file ID of the result file, if applicable */ private String fileId; - - /** - * Original file name, if applicable - */ + + /** Original file name, if applicable */ private String originalFileName; - - /** - * MIME type of the result, if applicable - */ + + /** MIME type of the result, if applicable */ private String contentType; - - /** - * Time when the job was created - */ + + /** Time when the job was created */ private LocalDateTime createdAt; - - /** - * Time when the job was completed - */ + + /** Time when the job was completed */ private LocalDateTime completedAt; - - /** - * The actual result object, if not a file - */ + + /** The actual result object, if not a file */ private Object result; - + /** * Create a new JobResult with the given job ID - * + * * @param jobId The job ID * @return A new JobResult */ @@ -75,10 +54,10 @@ public class JobResult { .createdAt(LocalDateTime.now()) .build(); } - + /** * Mark this job as complete with a file result - * + * * @param fileId The file ID of the result * @param originalFileName The original file name * @param contentType The content type of the file @@ -90,10 +69,10 @@ public class JobResult { this.contentType = contentType; this.completedAt = LocalDateTime.now(); } - + /** * Mark this job as complete with a general result - * + * * @param result The result object */ public void completeWithResult(Object result) { @@ -101,10 +80,10 @@ public class JobResult { this.result = result; this.completedAt = LocalDateTime.now(); } - + /** * Mark this job as failed with an error message - * + * * @param error The error message */ public void failWithError(String error) { @@ -112,4 +91,4 @@ public class JobResult { this.error = error; this.completedAt = LocalDateTime.now(); } -} \ No newline at end of file +} diff --git a/common/src/main/java/stirling/software/common/model/job/JobStats.java b/common/src/main/java/stirling/software/common/model/job/JobStats.java new file mode 100644 index 000000000..d336b95d4 --- /dev/null +++ b/common/src/main/java/stirling/software/common/model/job/JobStats.java @@ -0,0 +1,43 @@ +package stirling.software.common.model.job; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Represents statistics about jobs in the system */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobStats { + + /** Total number of jobs (active and completed) */ + private int totalJobs; + + /** Number of active (incomplete) jobs */ + private int activeJobs; + + /** Number of completed jobs */ + private int completedJobs; + + /** Number of failed jobs */ + private int failedJobs; + + /** Number of successful jobs */ + private int successfulJobs; + + /** Number of jobs with file results */ + private int fileResultJobs; + + /** The oldest active job's creation timestamp */ + private LocalDateTime oldestActiveJobTime; + + /** The newest active job's creation timestamp */ + private LocalDateTime newestActiveJobTime; + + /** The average processing time for completed jobs in milliseconds */ + private long averageProcessingTimeMs; +} diff --git a/common/src/main/java/stirling/software/common/service/FileStorage.java b/common/src/main/java/stirling/software/common/service/FileStorage.java index 64cffd00f..e200ded8a 100644 --- a/common/src/main/java/stirling/software/common/service/FileStorage.java +++ b/common/src/main/java/stirling/software/common/service/FileStorage.java @@ -13,22 +13,22 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * Service for storing and retrieving files with unique file IDs. - * Used by the AutoJobPostMapping system to handle file references. + * Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping + * system to handle file references. */ @Service @RequiredArgsConstructor @Slf4j public class FileStorage { - + @Value("${stirling.tempDir:/tmp/stirling-files}") private String tempDirPath; - + private final FileOrUploadService fileOrUploadService; - + /** * Store a file and return its unique ID - * + * * @param file The file to store * @return The unique ID assigned to the file * @throws IOException If there is an error storing the file @@ -36,20 +36,20 @@ public class FileStorage { public String storeFile(MultipartFile file) throws IOException { String fileId = generateFileId(); Path filePath = getFilePath(fileId); - + // Ensure the directory exists Files.createDirectories(filePath.getParent()); - + // Transfer the file to the storage location file.transferTo(filePath.toFile()); - + log.debug("Stored file with ID: {}", fileId); return fileId; } - + /** * Store a byte array as a file and return its unique ID - * + * * @param bytes The byte array to store * @param originalName The original name of the file (for extension) * @return The unique ID assigned to the file @@ -58,55 +58,55 @@ public class FileStorage { public String storeBytes(byte[] bytes, String originalName) throws IOException { String fileId = generateFileId(); Path filePath = getFilePath(fileId); - + // Ensure the directory exists Files.createDirectories(filePath.getParent()); - + // Write the bytes to the file Files.write(filePath, bytes); - + log.debug("Stored byte array with ID: {}", fileId); return fileId; } - + /** * Retrieve a file by its ID as a MultipartFile - * + * * @param fileId The ID of the file to retrieve * @return The file as a MultipartFile * @throws IOException If the file doesn't exist or can't be read */ public MultipartFile retrieveFile(String fileId) throws IOException { Path filePath = getFilePath(fileId); - + if (!Files.exists(filePath)) { throw new IOException("File not found with ID: " + fileId); } - + byte[] fileData = Files.readAllBytes(filePath); return fileOrUploadService.toMockMultipartFile(fileId, fileData); } - + /** * Retrieve a file by its ID as a byte array - * + * * @param fileId The ID of the file to retrieve * @return The file as a byte array * @throws IOException If the file doesn't exist or can't be read */ public byte[] retrieveBytes(String fileId) throws IOException { Path filePath = getFilePath(fileId); - + if (!Files.exists(filePath)) { throw new IOException("File not found with ID: " + fileId); } - + return Files.readAllBytes(filePath); } - + /** * Delete a file by its ID - * + * * @param fileId The ID of the file to delete * @return true if the file was deleted, false otherwise */ @@ -119,10 +119,10 @@ public class FileStorage { return false; } } - + /** * Check if a file exists by its ID - * + * * @param fileId The ID of the file to check * @return true if the file exists, false otherwise */ @@ -130,23 +130,23 @@ public class FileStorage { Path filePath = getFilePath(fileId); return Files.exists(filePath); } - + /** * Get the path for a file ID - * + * * @param fileId The ID of the file * @return The path to the file */ private Path getFilePath(String fileId) { return Path.of(tempDirPath).resolve(fileId); } - + /** * Generate a unique file ID - * + * * @return A unique file ID */ private String generateFileId() { return UUID.randomUUID().toString(); } -} \ No newline at end of file +} diff --git a/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 94c9824da..e1457b069 100644 --- a/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -4,37 +4,54 @@ import java.io.IOException; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.Supplier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.controller.WebSocketProgressController; import stirling.software.common.model.job.JobProgress; import stirling.software.common.model.job.JobResponse; -/** - * Service for executing jobs asynchronously or synchronously - */ +/** Service for executing jobs asynchronously or synchronously */ @Service -@RequiredArgsConstructor @Slf4j public class JobExecutorService { private final TaskManager taskManager; private final WebSocketProgressController webSocketSender; private final FileStorage fileStorage; - private final ExecutorService executor = Executors.newCachedThreadPool(); + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + private final long effectiveTimeoutMs; + + public JobExecutorService( + TaskManager taskManager, + WebSocketProgressController webSocketSender, + FileStorage fileStorage, + @Value("${spring.mvc.async.request-timeout:1200000}") long asyncRequestTimeoutMs, + @Value("${server.servlet.session.timeout:30m}") String sessionTimeout) { + this.taskManager = taskManager; + this.webSocketSender = webSocketSender; + this.fileStorage = fileStorage; + + // Parse session timeout and calculate effective timeout once during initialization + long sessionTimeoutMs = parseSessionTimeout(sessionTimeout); + this.effectiveTimeoutMs = Math.min(asyncRequestTimeoutMs, sessionTimeoutMs); + log.info( + "Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs); + } /** * Run a job either asynchronously or synchronously - * + * * @param async Whether to run the job asynchronously * @param work The work to be done * @return The response @@ -50,10 +67,22 @@ public class JobExecutorService { executor.execute( () -> { try { - Object result = work.get(); + log.debug( + "Running async job {} with timeout {} ms", + jobId, + effectiveTimeoutMs); + + // Execute with timeout + Object result = + executeWithTimeout(() -> work.get(), effectiveTimeoutMs); processJobResult(jobId, result); webSocketSender.sendProgress( jobId, new JobProgress(jobId, "Done", 100, "Complete")); + } catch (TimeoutException te) { + log.error("Job {} timed out after {} ms", jobId, effectiveTimeoutMs); + taskManager.setError(jobId, "Job timed out"); + webSocketSender.sendProgress( + jobId, new JobProgress(jobId, "Error", 100, "Job timed out")); } catch (Exception e) { log.error("Error executing job {}: {}", jobId, e.getMessage(), e); taskManager.setError(jobId, e.getMessage()); @@ -65,25 +94,32 @@ public class JobExecutorService { return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null)); } else { try { - Object result = work.get(); - + log.debug("Running sync job with timeout {} ms", effectiveTimeoutMs); + + // Execute with timeout + Object result = executeWithTimeout(() -> work.get(), effectiveTimeoutMs); + // If the result is already a ResponseEntity, return it directly if (result instanceof ResponseEntity) { return (ResponseEntity) result; } - + // Process different result types return handleResultForSyncJob(result); + } catch (TimeoutException te) { + log.error("Synchronous job timed out after {} ms", effectiveTimeoutMs); + return ResponseEntity.internalServerError() + .body("Job timed out after " + effectiveTimeoutMs + " ms"); } catch (Exception e) { log.error("Error executing synchronous job: {}", e.getMessage(), e); return ResponseEntity.internalServerError().body("Job failed: " + e.getMessage()); } } } - + /** * Process the result of an asynchronous job - * + * * @param jobId The job ID * @param result The result */ @@ -97,25 +133,27 @@ public class JobExecutorService { } else if (result instanceof ResponseEntity) { ResponseEntity response = (ResponseEntity) result; Object body = response.getBody(); - + if (body instanceof byte[]) { // Extract filename from content-disposition header if available String filename = "result.pdf"; String contentType = "application/pdf"; - + if (response.getHeaders().getContentDisposition() != null) { - String disposition = response.getHeaders().getContentDisposition().toString(); + String disposition = + response.getHeaders().getContentDisposition().toString(); if (disposition.contains("filename=")) { - filename = disposition.substring( - disposition.indexOf("filename=") + 9, - disposition.lastIndexOf("\"")); + filename = + disposition.substring( + disposition.indexOf("filename=") + 9, + disposition.lastIndexOf("\"")); } } - + if (response.getHeaders().getContentType() != null) { contentType = response.getHeaders().getContentType().toString(); } - + String fileId = fileStorage.storeBytes((byte[]) body, filename); taskManager.setFileResult(jobId, fileId, filename, contentType); log.debug("Stored ResponseEntity result with fileId: {}", fileId); @@ -124,26 +162,30 @@ public class JobExecutorService { if (body != null && body.toString().contains("fileId")) { try { // Try to extract fileId using reflection - java.lang.reflect.Method getFileId = body.getClass().getMethod("getFileId"); + java.lang.reflect.Method getFileId = + body.getClass().getMethod("getFileId"); String fileId = (String) getFileId.invoke(body); - + if (fileId != null && !fileId.isEmpty()) { // Try to get filename and content type String filename = "result.pdf"; String contentType = "application/pdf"; - + try { - java.lang.reflect.Method getOriginalFileName = body.getClass().getMethod("getOriginalFilename"); + java.lang.reflect.Method getOriginalFileName = + body.getClass().getMethod("getOriginalFilename"); String origName = (String) getOriginalFileName.invoke(body); if (origName != null && !origName.isEmpty()) { filename = origName; } } catch (Exception e) { - log.debug("Could not get original filename: {}", e.getMessage()); + log.debug( + "Could not get original filename: {}", e.getMessage()); } - + try { - java.lang.reflect.Method getContentType = body.getClass().getMethod("getContentType"); + java.lang.reflect.Method getContentType = + body.getClass().getMethod("getContentType"); String ct = (String) getContentType.invoke(body); if (ct != null && !ct.isEmpty()) { contentType = ct; @@ -151,18 +193,20 @@ public class JobExecutorService { } catch (Exception e) { log.debug("Could not get content type: {}", e.getMessage()); } - + taskManager.setFileResult(jobId, fileId, filename, contentType); log.debug("Extracted fileId from response body: {}", fileId); - + taskManager.setComplete(jobId); return; } } catch (Exception e) { - log.debug("Failed to extract fileId from response body: {}", e.getMessage()); + log.debug( + "Failed to extract fileId from response body: {}", + e.getMessage()); } } - + // Store generic result taskManager.setResult(jobId, body); } @@ -170,26 +214,25 @@ public class JobExecutorService { MultipartFile file = (MultipartFile) result; String fileId = fileStorage.storeFile(file); taskManager.setFileResult( - jobId, - fileId, - file.getOriginalFilename(), - file.getContentType()); + jobId, fileId, file.getOriginalFilename(), file.getContentType()); log.debug("Stored MultipartFile result with fileId: {}", fileId); } else { // Check if result has a fileId field if (result != null) { try { // Try to extract fileId using reflection - java.lang.reflect.Method getFileId = result.getClass().getMethod("getFileId"); + java.lang.reflect.Method getFileId = + result.getClass().getMethod("getFileId"); String fileId = (String) getFileId.invoke(result); - + if (fileId != null && !fileId.isEmpty()) { // Try to get filename and content type String filename = "result.pdf"; String contentType = "application/pdf"; - + try { - java.lang.reflect.Method getOriginalFileName = result.getClass().getMethod("getOriginalFilename"); + java.lang.reflect.Method getOriginalFileName = + result.getClass().getMethod("getOriginalFilename"); String origName = (String) getOriginalFileName.invoke(result); if (origName != null && !origName.isEmpty()) { filename = origName; @@ -197,9 +240,10 @@ public class JobExecutorService { } catch (Exception e) { log.debug("Could not get original filename: {}", e.getMessage()); } - + try { - java.lang.reflect.Method getContentType = result.getClass().getMethod("getContentType"); + java.lang.reflect.Method getContentType = + result.getClass().getMethod("getContentType"); String ct = (String) getContentType.invoke(result); if (ct != null && !ct.isEmpty()) { contentType = ct; @@ -207,32 +251,33 @@ public class JobExecutorService { } catch (Exception e) { log.debug("Could not get content type: {}", e.getMessage()); } - + taskManager.setFileResult(jobId, fileId, filename, contentType); log.debug("Extracted fileId from result object: {}", fileId); - + taskManager.setComplete(jobId); return; } } catch (Exception e) { - log.debug("Failed to extract fileId from result object: {}", e.getMessage()); + log.debug( + "Failed to extract fileId from result object: {}", e.getMessage()); } } - + // Default case: store the result as is taskManager.setResult(jobId, result); } - + taskManager.setComplete(jobId); } catch (Exception e) { log.error("Error processing job result: {}", e.getMessage(), e); taskManager.setError(jobId, "Error processing result: " + e.getMessage()); } } - + /** * Handle different result types for synchronous jobs - * + * * @param result The result object * @return The appropriate ResponseEntity * @throws IOException If there is an error processing the result @@ -242,7 +287,8 @@ public class JobExecutorService { // Return byte array as PDF return ResponseEntity.ok() .contentType(MediaType.APPLICATION_PDF) - .header(HttpHeaders.CONTENT_DISPOSITION, + .header( + HttpHeaders.CONTENT_DISPOSITION, "form-data; name=\"attachment\"; filename=\"result.pdf\"") .body(result); } else if (result instanceof MultipartFile) { @@ -250,13 +296,74 @@ public class JobExecutorService { MultipartFile file = (MultipartFile) result; return ResponseEntity.ok() .contentType(MediaType.parseMediaType(file.getContentType())) - .header(HttpHeaders.CONTENT_DISPOSITION, - "form-data; name=\"attachment\"; filename=\"" + - file.getOriginalFilename() + "\"") + .header( + HttpHeaders.CONTENT_DISPOSITION, + "form-data; name=\"attachment\"; filename=\"" + + file.getOriginalFilename() + + "\"") .body(file.getBytes()); } else { // Default case: return as JSON return ResponseEntity.ok(result); } } + + /** + * Parse session timeout string (e.g., "30m", "1h") to milliseconds + * + * @param timeout The timeout string + * @return The timeout in milliseconds + */ + private long parseSessionTimeout(String timeout) { + if (timeout == null || timeout.isEmpty()) { + return 30 * 60 * 1000; // Default: 30 minutes + } + + try { + String value = timeout.replaceAll("[^\\d.]", ""); + String unit = timeout.replaceAll("[\\d.]", ""); + + double numericValue = Double.parseDouble(value); + + return switch (unit.toLowerCase()) { + case "s" -> (long) (numericValue * 1000); + case "m" -> (long) (numericValue * 60 * 1000); + case "h" -> (long) (numericValue * 60 * 60 * 1000); + case "d" -> (long) (numericValue * 24 * 60 * 60 * 1000); + default -> (long) (numericValue * 60 * 1000); // Default to minutes + }; + } catch (Exception e) { + log.warn("Could not parse session timeout '{}', using default", timeout); + return 30 * 60 * 1000; // Default: 30 minutes + } + } + + /** + * Execute a supplier with a timeout + * + * @param supplier The supplier to execute + * @param timeoutMs The timeout in milliseconds + * @return The result from the supplier + * @throws TimeoutException If the execution times out + * @throws Exception If the supplier throws an exception + */ + private T executeWithTimeout(Supplier supplier, long timeoutMs) + throws TimeoutException, Exception { + java.util.concurrent.CompletableFuture future = + java.util.concurrent.CompletableFuture.supplyAsync(supplier); + + try { + return future.get(timeoutMs, TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.TimeoutException e) { + future.cancel(true); + throw new TimeoutException("Execution timed out after " + timeoutMs + " ms"); + } catch (java.util.concurrent.ExecutionException e) { + throw (Exception) e.getCause(); + } catch (java.util.concurrent.CancellationException e) { + throw new Exception("Execution was cancelled", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new Exception("Execution was interrupted", e); + } + } } diff --git a/common/src/main/java/stirling/software/common/service/TaskManager.java b/common/src/main/java/stirling/software/common/service/TaskManager.java index 41c133219..30b0ea4de 100644 --- a/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -1,47 +1,65 @@ package stirling.software.common.service; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RestController; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.PreDestroy; + import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.JobStats; -/** - * Manages async tasks and their results - */ +/** Manages async tasks and their results */ @Service -@RequiredArgsConstructor @Slf4j public class TaskManager { private final Map jobResults = new ConcurrentHashMap<>(); - + @Value("${stirling.jobResultExpiryMinutes:30}") private int jobResultExpiryMinutes = 30; - + private final FileStorage fileStorage; - + private final ScheduledExecutorService cleanupExecutor = + Executors.newSingleThreadScheduledExecutor(); + + /** Initialize the task manager and start the cleanup scheduler */ + public TaskManager(FileStorage fileStorage) { + this.fileStorage = fileStorage; + + // Schedule periodic cleanup of old job results + cleanupExecutor.scheduleAtFixedRate( + this::cleanupOldJobs, + 10, // Initial delay + 10, // Interval + TimeUnit.MINUTES); + + log.info( + "Task manager initialized with job result expiry of {} minutes", + jobResultExpiryMinutes); + } + /** * Create a new task with the given job ID - * + * * @param jobId The job ID */ public void createTask(String jobId) { jobResults.put(jobId, JobResult.createNew(jobId)); log.debug("Created task with job ID: {}", jobId); } - + /** * Set the result of a task as a general object - * + * * @param jobId The job ID * @param result The result object */ @@ -50,24 +68,25 @@ public class TaskManager { jobResult.completeWithResult(result); log.debug("Set result for job ID: {}", jobId); } - + /** * Set the result of a task as a file - * + * * @param jobId The job ID * @param fileId The file ID * @param originalFileName The original file name * @param contentType The content type of the file */ - public void setFileResult(String jobId, String fileId, String originalFileName, String contentType) { + public void setFileResult( + String jobId, String fileId, String originalFileName, String contentType) { JobResult jobResult = getOrCreateJobResult(jobId); jobResult.completeWithFile(fileId, originalFileName, contentType); log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId); } - + /** * Set an error for a task - * + * * @param jobId The job ID * @param error The error message */ @@ -76,24 +95,26 @@ public class TaskManager { jobResult.failWithError(error); log.debug("Set error for job ID: {}: {}", jobId, error); } - + /** * Mark a task as complete - * + * * @param jobId The job ID */ public void setComplete(String jobId) { JobResult jobResult = getOrCreateJobResult(jobId); - if (jobResult.getResult() == null && jobResult.getFileId() == null && jobResult.getError() == null) { + if (jobResult.getResult() == null + && jobResult.getFileId() == null + && jobResult.getError() == null) { // If no result or error has been set, mark it as complete with an empty result jobResult.completeWithResult("Task completed successfully"); } log.debug("Marked job ID: {} as complete", jobId); } - + /** * Check if a task is complete - * + * * @param jobId The job ID * @return true if the task is complete, false otherwise */ @@ -101,86 +122,153 @@ public class TaskManager { JobResult result = jobResults.get(jobId); return result != null && result.isComplete(); } - + /** * Get the result of a task - * + * * @param jobId The job ID * @return The result object, or null if the task doesn't exist or is not complete */ public JobResult getJobResult(String jobId) { return jobResults.get(jobId); } - + + /** + * Get statistics about all jobs in the system + * + * @return Job statistics + */ + public JobStats getJobStats() { + int totalJobs = jobResults.size(); + int activeJobs = 0; + int completedJobs = 0; + int failedJobs = 0; + int successfulJobs = 0; + int fileResultJobs = 0; + + LocalDateTime oldestActiveJobTime = null; + LocalDateTime newestActiveJobTime = null; + long totalProcessingTimeMs = 0; + + for (JobResult result : jobResults.values()) { + if (result.isComplete()) { + completedJobs++; + + // Calculate processing time for completed jobs + if (result.getCreatedAt() != null && result.getCompletedAt() != null) { + long processingTimeMs = + java.time.Duration.between( + result.getCreatedAt(), result.getCompletedAt()) + .toMillis(); + totalProcessingTimeMs += processingTimeMs; + } + + if (result.getError() != null) { + failedJobs++; + } else { + successfulJobs++; + if (result.getFileId() != null) { + fileResultJobs++; + } + } + } else { + activeJobs++; + + // Track oldest and newest active jobs + if (result.getCreatedAt() != null) { + if (oldestActiveJobTime == null + || result.getCreatedAt().isBefore(oldestActiveJobTime)) { + oldestActiveJobTime = result.getCreatedAt(); + } + + if (newestActiveJobTime == null + || result.getCreatedAt().isAfter(newestActiveJobTime)) { + newestActiveJobTime = result.getCreatedAt(); + } + } + } + } + + // Calculate average processing time + long averageProcessingTimeMs = + completedJobs > 0 ? totalProcessingTimeMs / completedJobs : 0; + + return JobStats.builder() + .totalJobs(totalJobs) + .activeJobs(activeJobs) + .completedJobs(completedJobs) + .failedJobs(failedJobs) + .successfulJobs(successfulJobs) + .fileResultJobs(fileResultJobs) + .oldestActiveJobTime(oldestActiveJobTime) + .newestActiveJobTime(newestActiveJobTime) + .averageProcessingTimeMs(averageProcessingTimeMs) + .build(); + } + /** * Get or create a job result - * + * * @param jobId The job ID * @return The job result */ private JobResult getOrCreateJobResult(String jobId) { return jobResults.computeIfAbsent(jobId, JobResult::createNew); } - - /** - * REST controller for job-related endpoints - */ - @RestController - public class JobController { - - /** - * Get the status of a job - * - * @param jobId The job ID - * @return The job result - */ - @GetMapping("/api/v1/general/job/{jobId}") - public ResponseEntity getJobStatus(@PathVariable("jobId") String jobId) { - JobResult result = jobResults.get(jobId); - if (result == null) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(result); - } - - /** - * Get the result of a job - * - * @param jobId The job ID - * @return The job result - */ - @GetMapping("/api/v1/general/job/{jobId}/result") - public ResponseEntity getJobResult(@PathVariable("jobId") String jobId) { - JobResult result = jobResults.get(jobId); - if (result == null) { - return ResponseEntity.notFound().build(); - } - - if (!result.isComplete()) { - return ResponseEntity.badRequest().body("Job is not complete yet"); - } - - if (result.getError() != null) { - return ResponseEntity.badRequest().body("Job failed: " + result.getError()); - } - - if (result.getFileId() != null) { - try { - byte[] fileContent = fileStorage.retrieveBytes(result.getFileId()); - return ResponseEntity.ok() - .header("Content-Type", result.getContentType()) - .header("Content-Disposition", - "form-data; name=\"attachment\"; filename=\"" + - result.getOriginalFileName() + "\"") - .body(fileContent); - } catch (Exception e) { - log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e); - return ResponseEntity.internalServerError() - .body("Error retrieving file: " + e.getMessage()); + + /** Clean up old completed job results */ + public void cleanupOldJobs() { + LocalDateTime expiryThreshold = + LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES); + int removedCount = 0; + + try { + for (Map.Entry entry : jobResults.entrySet()) { + JobResult result = entry.getValue(); + + // Remove completed jobs that are older than the expiry threshold + if (result.isComplete() + && result.getCompletedAt() != null + && result.getCompletedAt().isBefore(expiryThreshold)) { + + // If the job has a file result, delete the file + if (result.getFileId() != null) { + try { + fileStorage.deleteFile(result.getFileId()); + } catch (Exception e) { + log.warn( + "Failed to delete file for job {}: {}", + entry.getKey(), + e.getMessage()); + } + } + + // Remove the job result + jobResults.remove(entry.getKey()); + removedCount++; } } - - return ResponseEntity.ok(result.getResult()); + + if (removedCount > 0) { + log.info("Cleaned up {} expired job results", removedCount); + } + } catch (Exception e) { + log.error("Error during job cleanup: {}", e.getMessage(), e); + } + } + + /** Shutdown the cleanup executor */ + @PreDestroy + public void shutdown() { + try { + log.info("Shutting down job result cleanup executor"); + cleanupExecutor.shutdown(); + if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + cleanupExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + cleanupExecutor.shutdownNow(); } } } diff --git a/common/src/test/java/stirling/software/common/controller/JobControllerTest.java b/common/src/test/java/stirling/software/common/controller/JobControllerTest.java new file mode 100644 index 000000000..c0727f1e1 --- /dev/null +++ b/common/src/test/java/stirling/software/common/controller/JobControllerTest.java @@ -0,0 +1,227 @@ +package stirling.software.common.controller; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.JobStats; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.TaskManager; + +class JobControllerTest { + + @Mock + private TaskManager taskManager; + + @Mock + private FileStorage fileStorage; + + @InjectMocks + private JobController controller; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testGetJobStatus_ExistingJob() { + // Arrange + String jobId = "test-job-id"; + JobResult mockResult = new JobResult(); + mockResult.setJobId(jobId); + when(taskManager.getJobResult(jobId)).thenReturn(mockResult); + + // Act + ResponseEntity response = controller.getJobStatus(jobId); + + // Assert + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockResult, response.getBody()); + } + + @Test + void testGetJobStatus_NonExistentJob() { + // Arrange + String jobId = "non-existent-job"; + when(taskManager.getJobResult(jobId)).thenReturn(null); + + // Act + ResponseEntity response = controller.getJobStatus(jobId); + + // Assert + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + } + + @Test + void testGetJobResult_CompletedSuccessfulWithObject() { + // Arrange + String jobId = "test-job-id"; + JobResult mockResult = new JobResult(); + mockResult.setJobId(jobId); + mockResult.setComplete(true); + String resultObject = "Test result"; + mockResult.completeWithResult(resultObject); + + when(taskManager.getJobResult(jobId)).thenReturn(mockResult); + + // Act + ResponseEntity response = controller.getJobResult(jobId); + + // Assert + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(resultObject, response.getBody()); + } + + @Test + void testGetJobResult_CompletedSuccessfulWithFile() throws Exception { + // Arrange + String jobId = "test-job-id"; + String fileId = "file-id"; + String originalFileName = "test.pdf"; + String contentType = "application/pdf"; + byte[] fileContent = "Test file content".getBytes(); + + JobResult mockResult = new JobResult(); + mockResult.setJobId(jobId); + mockResult.completeWithFile(fileId, originalFileName, contentType); + + when(taskManager.getJobResult(jobId)).thenReturn(mockResult); + when(fileStorage.retrieveBytes(fileId)).thenReturn(fileContent); + + // Act + ResponseEntity response = controller.getJobResult(jobId); + + // Assert + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(contentType, response.getHeaders().getFirst("Content-Type")); + assertTrue(response.getHeaders().getFirst("Content-Disposition").contains(originalFileName)); + assertEquals(fileContent, response.getBody()); + } + + @Test + void testGetJobResult_CompletedWithError() { + // Arrange + String jobId = "test-job-id"; + String errorMessage = "Test error"; + + JobResult mockResult = new JobResult(); + mockResult.setJobId(jobId); + mockResult.failWithError(errorMessage); + + when(taskManager.getJobResult(jobId)).thenReturn(mockResult); + + // Act + ResponseEntity response = controller.getJobResult(jobId); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody().toString().contains(errorMessage)); + } + + @Test + void testGetJobResult_IncompleteJob() { + // Arrange + String jobId = "test-job-id"; + + JobResult mockResult = new JobResult(); + mockResult.setJobId(jobId); + mockResult.setComplete(false); + + when(taskManager.getJobResult(jobId)).thenReturn(mockResult); + + // Act + ResponseEntity response = controller.getJobResult(jobId); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody().toString().contains("not complete")); + } + + @Test + void testGetJobResult_NonExistentJob() { + // Arrange + String jobId = "non-existent-job"; + when(taskManager.getJobResult(jobId)).thenReturn(null); + + // Act + ResponseEntity response = controller.getJobResult(jobId); + + // Assert + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + } + + @Test + void testGetJobResult_ErrorRetrievingFile() throws Exception { + // Arrange + String jobId = "test-job-id"; + String fileId = "file-id"; + String originalFileName = "test.pdf"; + String contentType = "application/pdf"; + + JobResult mockResult = new JobResult(); + mockResult.setJobId(jobId); + mockResult.completeWithFile(fileId, originalFileName, contentType); + + when(taskManager.getJobResult(jobId)).thenReturn(mockResult); + when(fileStorage.retrieveBytes(fileId)).thenThrow(new RuntimeException("File not found")); + + // Act + ResponseEntity response = controller.getJobResult(jobId); + + // Assert + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertTrue(response.getBody().toString().contains("Error retrieving file")); + } + + @Test + void testGetJobStats() { + // Arrange + JobStats mockStats = JobStats.builder() + .totalJobs(10) + .activeJobs(3) + .completedJobs(7) + .build(); + + when(taskManager.getJobStats()).thenReturn(mockStats); + + // Act + ResponseEntity response = controller.getJobStats(); + + // Assert + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(mockStats, response.getBody()); + } + + @Test + void testCleanupOldJobs() { + // Arrange + when(taskManager.getJobStats()) + .thenReturn(JobStats.builder().totalJobs(10).build()) + .thenReturn(JobStats.builder().totalJobs(7).build()); + + // Act + ResponseEntity response = controller.cleanupOldJobs(); + + // Assert + assertEquals(HttpStatus.OK, response.getStatusCode()); + + @SuppressWarnings("unchecked") + Map responseBody = (Map) response.getBody(); + assertEquals("Cleanup complete", responseBody.get("message")); + assertEquals(3, responseBody.get("removedJobs")); + assertEquals(7, responseBody.get("remainingJobs")); + + verify(taskManager).cleanupOldJobs(); + } +} \ No newline at end of file diff --git a/common/src/test/java/stirling/software/common/controller/WebSocketProgressControllerTest.java b/common/src/test/java/stirling/software/common/controller/WebSocketProgressControllerTest.java new file mode 100644 index 000000000..d52649cb9 --- /dev/null +++ b/common/src/test/java/stirling/software/common/controller/WebSocketProgressControllerTest.java @@ -0,0 +1,69 @@ +package stirling.software.common.controller; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import stirling.software.common.model.job.JobProgress; + +class WebSocketProgressControllerTest { + + @Mock + private SimpMessagingTemplate messagingTemplate; + + @InjectMocks + private WebSocketProgressController controller; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testSendProgress_WithMessagingTemplate() { + // Arrange + String jobId = "test-job-id"; + JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing"); + + // Act + controller.sendProgress(jobId, progress); + + // Assert + verify(messagingTemplate).convertAndSend("/topic/progress/" + jobId, progress); + } + + @Test + void testSendProgress_WithNullMessagingTemplate() { + // Arrange + WebSocketProgressController controllerWithNullTemplate = new WebSocketProgressController(); + String jobId = "test-job-id"; + JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing"); + + // Act - should not throw exception even with null template + controllerWithNullTemplate.sendProgress(jobId, progress); + + // No assertion needed - test passes if no exception is thrown + } + + @Test + void testSetMessagingTemplate() { + // Arrange + WebSocketProgressController newController = new WebSocketProgressController(); + SimpMessagingTemplate newTemplate = mock(SimpMessagingTemplate.class); + + // Act + newController.setMessagingTemplate(newTemplate); + String jobId = "test-job-id"; + JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing"); + newController.sendProgress(jobId, progress); + + // Assert + verify(newTemplate).convertAndSend("/topic/progress/" + jobId, progress); + } +} \ No newline at end of file diff --git a/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/common/src/test/java/stirling/software/common/service/FileStorageTest.java new file mode 100644 index 000000000..f349b5305 --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -0,0 +1,190 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.AdditionalAnswers.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +class FileStorageTest { + + @TempDir + Path tempDir; + + @Mock + private FileOrUploadService fileOrUploadService; + + @InjectMocks + private FileStorage fileStorage; + + private MultipartFile mockFile; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString()); + + // Create a mock MultipartFile + mockFile = mock(MultipartFile.class); + when(mockFile.getOriginalFilename()).thenReturn("test.pdf"); + when(mockFile.getContentType()).thenReturn("application/pdf"); + } + + @Test + void testStoreFile() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + when(mockFile.getBytes()).thenReturn(fileContent); + + // Set up mock to handle transferTo by writing the file + doAnswer(invocation -> { + java.io.File file = invocation.getArgument(0); + Files.write(file.toPath(), fileContent); + return null; + }).when(mockFile).transferTo(any(java.io.File.class)); + + // Act + String fileId = fileStorage.storeFile(mockFile); + + // Assert + assertNotNull(fileId); + assertTrue(Files.exists(tempDir.resolve(fileId))); + verify(mockFile).transferTo(any(java.io.File.class)); + } + + @Test + void testStoreBytes() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String originalName = "test.pdf"; + + // Act + String fileId = fileStorage.storeBytes(fileContent, originalName); + + // Assert + assertNotNull(fileId); + assertTrue(Files.exists(tempDir.resolve(fileId))); + assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId))); + } + + @Test + void testRetrieveFile() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String fileId = UUID.randomUUID().toString(); + Path filePath = tempDir.resolve(fileId); + Files.write(filePath, fileContent); + + MultipartFile expectedFile = mock(MultipartFile.class); + when(fileOrUploadService.toMockMultipartFile(eq(fileId), eq(fileContent))) + .thenReturn(expectedFile); + + // Act + MultipartFile result = fileStorage.retrieveFile(fileId); + + // Assert + assertSame(expectedFile, result); + verify(fileOrUploadService).toMockMultipartFile(eq(fileId), eq(fileContent)); + } + + @Test + void testRetrieveBytes() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String fileId = UUID.randomUUID().toString(); + Path filePath = tempDir.resolve(fileId); + Files.write(filePath, fileContent); + + // Act + byte[] result = fileStorage.retrieveBytes(fileId); + + // Assert + assertArrayEquals(fileContent, result); + } + + @Test + void testRetrieveFile_FileNotFound() { + // Arrange + String nonExistentFileId = "non-existent-file"; + + // Act & Assert + assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId)); + } + + @Test + void testRetrieveBytes_FileNotFound() { + // Arrange + String nonExistentFileId = "non-existent-file"; + + // Act & Assert + assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId)); + } + + @Test + void testDeleteFile() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String fileId = UUID.randomUUID().toString(); + Path filePath = tempDir.resolve(fileId); + Files.write(filePath, fileContent); + + // Act + boolean result = fileStorage.deleteFile(fileId); + + // Assert + assertTrue(result); + assertFalse(Files.exists(filePath)); + } + + @Test + void testDeleteFile_FileNotFound() { + // Arrange + String nonExistentFileId = "non-existent-file"; + + // Act + boolean result = fileStorage.deleteFile(nonExistentFileId); + + // Assert + assertFalse(result); + } + + @Test + void testFileExists() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String fileId = UUID.randomUUID().toString(); + Path filePath = tempDir.resolve(fileId); + Files.write(filePath, fileContent); + + // Act + boolean result = fileStorage.fileExists(fileId); + + // Assert + assertTrue(result); + } + + @Test + void testFileExists_FileNotFound() { + // Arrange + String nonExistentFileId = "non-existent-file"; + + // Act + boolean result = fileStorage.fileExists(nonExistentFileId); + + // Assert + assertFalse(result); + } +} \ No newline at end of file diff --git a/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/common/src/test/java/stirling/software/common/service/TaskManagerTest.java new file mode 100644 index 000000000..04a599aa0 --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -0,0 +1,255 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.JobStats; + +class TaskManagerTest { + + @Mock + private FileStorage fileStorage; + + @InjectMocks + private TaskManager taskManager; + + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @Test + void testCreateTask() { + // Act + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertEquals(jobId, result.getJobId()); + assertFalse(result.isComplete()); + assertNotNull(result.getCreatedAt()); + } + + @Test + void testSetResult() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + Object resultObject = "Test result"; + + // Act + taskManager.setResult(jobId, resultObject); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals(resultObject, result.getResult()); + assertNotNull(result.getCompletedAt()); + } + + @Test + void testSetFileResult() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + String fileId = "file-id"; + String originalFileName = "test.pdf"; + String contentType = "application/pdf"; + + // Act + taskManager.setFileResult(jobId, fileId, originalFileName, contentType); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals(fileId, result.getFileId()); + assertEquals(originalFileName, result.getOriginalFileName()); + assertEquals(contentType, result.getContentType()); + assertNotNull(result.getCompletedAt()); + } + + @Test + void testSetError() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + String errorMessage = "Test error"; + + // Act + taskManager.setError(jobId, errorMessage); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals(errorMessage, result.getError()); + assertNotNull(result.getCompletedAt()); + } + + @Test + void testSetComplete_WithExistingResult() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + Object resultObject = "Test result"; + taskManager.setResult(jobId, resultObject); + + // Act + taskManager.setComplete(jobId); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals(resultObject, result.getResult()); + } + + @Test + void testSetComplete_WithoutExistingResult() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + + // Act + taskManager.setComplete(jobId); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals("Task completed successfully", result.getResult()); + } + + @Test + void testIsComplete() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + + // Assert - not complete initially + assertFalse(taskManager.isComplete(jobId)); + + // Act - mark as complete + taskManager.setComplete(jobId); + + // Assert - now complete + assertTrue(taskManager.isComplete(jobId)); + } + + @Test + void testGetJobStats() { + // Arrange + // 1. Create active job + String activeJobId = "active-job"; + taskManager.createTask(activeJobId); + + // 2. Create completed successful job with file + String successFileJobId = "success-file-job"; + taskManager.createTask(successFileJobId); + taskManager.setFileResult(successFileJobId, "file-id", "test.pdf", "application/pdf"); + + // 3. Create completed successful job without file + String successJobId = "success-job"; + taskManager.createTask(successJobId); + taskManager.setResult(successJobId, "Result"); + + // 4. Create failed job + String failedJobId = "failed-job"; + taskManager.createTask(failedJobId); + taskManager.setError(failedJobId, "Error message"); + + // Act + JobStats stats = taskManager.getJobStats(); + + // Assert + assertEquals(4, stats.getTotalJobs()); + assertEquals(1, stats.getActiveJobs()); + assertEquals(3, stats.getCompletedJobs()); + assertEquals(1, stats.getFailedJobs()); + assertEquals(2, stats.getSuccessfulJobs()); + assertEquals(1, stats.getFileResultJobs()); + assertNotNull(stats.getNewestActiveJobTime()); + assertNotNull(stats.getOldestActiveJobTime()); + assertTrue(stats.getAverageProcessingTimeMs() >= 0); + } + + @Test + void testCleanupOldJobs() throws Exception { + // Arrange + // 1. Create a recent completed job + String recentJobId = "recent-job"; + taskManager.createTask(recentJobId); + taskManager.setResult(recentJobId, "Result"); + + // 2. Create an old completed job with file result + String oldJobId = "old-job"; + taskManager.createTask(oldJobId); + JobResult oldJob = taskManager.getJobResult(oldJobId); + + // Manually set the completion time to be older than the expiry + LocalDateTime oldTime = LocalDateTime.now().minusHours(1); + ReflectionTestUtils.setField(oldJob, "completedAt", oldTime); + ReflectionTestUtils.setField(oldJob, "complete", true); + ReflectionTestUtils.setField(oldJob, "fileId", "file-id"); + ReflectionTestUtils.setField(oldJob, "originalFileName", "test.pdf"); + ReflectionTestUtils.setField(oldJob, "contentType", "application/pdf"); + + when(fileStorage.deleteFile("file-id")).thenReturn(true); + + // Obtain access to the private jobResults map + Map jobResultsMap = (Map) ReflectionTestUtils.getField(taskManager, "jobResults"); + + // 3. Create an active job + String activeJobId = "active-job"; + taskManager.createTask(activeJobId); + + // Verify all jobs are in the map + assertTrue(jobResultsMap.containsKey(recentJobId)); + assertTrue(jobResultsMap.containsKey(oldJobId)); + assertTrue(jobResultsMap.containsKey(activeJobId)); + + // Act + taskManager.cleanupOldJobs(); + + // Assert - the old job should be removed + assertFalse(jobResultsMap.containsKey(oldJobId)); + assertTrue(jobResultsMap.containsKey(recentJobId)); + assertTrue(jobResultsMap.containsKey(activeJobId)); + verify(fileStorage).deleteFile("file-id"); + } + + @Test + void testShutdown() throws Exception { + // This mainly tests that the shutdown method doesn't throw exceptions + taskManager.shutdown(); + + // Verify the executor service is shutdown + // This is difficult to test directly, but we can verify it doesn't throw exceptions + } +} \ No newline at end of file