Tests cleanup and stuff

This commit is contained in:
Anthony Stirling 2025-06-02 11:06:20 +01:00
parent 0328333d81
commit 02aad40aa0
12 changed files with 1310 additions and 228 deletions

View File

@ -51,5 +51,16 @@ dependencies {
annotationProcessor "org.projectlombok:lombok:$lombokVersion" annotationProcessor "org.projectlombok:lombok:$lombokVersion"
testImplementation "org.springframework.boot:spring-boot-starter-test" 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' testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
} }
test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
}
}

View File

@ -1,7 +1,5 @@
package stirling.software.common.aop; package stirling.software.common.aop;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.IOException; import java.io.IOException;
import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.ProceedingJoinPoint;
@ -42,7 +40,7 @@ public class AutoJobAspect {
for (int i = 0; i < args.length; i++) { for (int i = 0; i < args.length; i++) {
Object arg = args[i]; Object arg = args[i];
if (arg instanceof PDFFile pdfFile) { if (arg instanceof PDFFile pdfFile) {
// Case 1: fileId is provided but no fileInput // Case 1: fileId is provided but no fileInput
if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) { if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) {
@ -54,24 +52,25 @@ public class AutoJobAspect {
throw new RuntimeException( throw new RuntimeException(
"Failed to resolve file by ID: " + pdfFile.getFileId(), e); "Failed to resolve file by ID: " + pdfFile.getFileId(), e);
} }
} }
// Case 2: For async requests, we need to make a copy of the MultipartFile // Case 2: For async requests, we need to make a copy of the MultipartFile
else if (isAsyncRequest && pdfFile.getFileInput() != null) { else if (isAsyncRequest && pdfFile.getFileInput() != null) {
try { try {
log.debug("Making persistent copy of uploaded file for async processing"); log.debug("Making persistent copy of uploaded file for async processing");
MultipartFile originalFile = pdfFile.getFileInput(); MultipartFile originalFile = pdfFile.getFileInput();
String fileId = fileStorage.storeFile(originalFile); String fileId = fileStorage.storeFile(originalFile);
// Store the fileId for later reference // Store the fileId for later reference
pdfFile.setFileId(fileId); pdfFile.setFileId(fileId);
// Replace the original MultipartFile with our persistent copy // Replace the original MultipartFile with our persistent copy
MultipartFile persistentFile = fileStorage.retrieveFile(fileId); MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
pdfFile.setFileInput(persistentFile); pdfFile.setFileInput(persistentFile);
log.debug("Created persistent file copy with fileId: {}", fileId); log.debug("Created persistent file copy with fileId: {}", fileId);
} catch (IOException e) { } 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);
} }
} }
} }

View File

@ -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<JobStats> 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));
}
}

View File

@ -7,64 +7,43 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; 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 @Data
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class JobResult { public class JobResult {
/** /** The job ID */
* The job ID
*/
private String jobId; private String jobId;
/** /** Flag indicating if the job is complete */
* Flag indicating if the job is complete
*/
private boolean complete; private boolean complete;
/** /** Error message if the job failed */
* Error message if the job failed
*/
private String error; private String error;
/** /** The file ID of the result file, if applicable */
* The file ID of the result file, if applicable
*/
private String fileId; private String fileId;
/** /** Original file name, if applicable */
* Original file name, if applicable
*/
private String originalFileName; private String originalFileName;
/** /** MIME type of the result, if applicable */
* MIME type of the result, if applicable
*/
private String contentType; private String contentType;
/** /** Time when the job was created */
* Time when the job was created
*/
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** /** Time when the job was completed */
* Time when the job was completed
*/
private LocalDateTime completedAt; private LocalDateTime completedAt;
/** /** The actual result object, if not a file */
* The actual result object, if not a file
*/
private Object result; private Object result;
/** /**
* Create a new JobResult with the given job ID * Create a new JobResult with the given job ID
* *
* @param jobId The job ID * @param jobId The job ID
* @return A new JobResult * @return A new JobResult
*/ */
@ -75,10 +54,10 @@ public class JobResult {
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.build(); .build();
} }
/** /**
* Mark this job as complete with a file result * Mark this job as complete with a file result
* *
* @param fileId The file ID of the result * @param fileId The file ID of the result
* @param originalFileName The original file name * @param originalFileName The original file name
* @param contentType The content type of the file * @param contentType The content type of the file
@ -90,10 +69,10 @@ public class JobResult {
this.contentType = contentType; this.contentType = contentType;
this.completedAt = LocalDateTime.now(); this.completedAt = LocalDateTime.now();
} }
/** /**
* Mark this job as complete with a general result * Mark this job as complete with a general result
* *
* @param result The result object * @param result The result object
*/ */
public void completeWithResult(Object result) { public void completeWithResult(Object result) {
@ -101,10 +80,10 @@ public class JobResult {
this.result = result; this.result = result;
this.completedAt = LocalDateTime.now(); this.completedAt = LocalDateTime.now();
} }
/** /**
* Mark this job as failed with an error message * Mark this job as failed with an error message
* *
* @param error The error message * @param error The error message
*/ */
public void failWithError(String error) { public void failWithError(String error) {
@ -112,4 +91,4 @@ public class JobResult {
this.error = error; this.error = error;
this.completedAt = LocalDateTime.now(); this.completedAt = LocalDateTime.now();
} }
} }

View File

@ -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;
}

View File

@ -13,22 +13,22 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* Service for storing and retrieving files with unique file IDs. * Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
* Used by the AutoJobPostMapping system to handle file references. * system to handle file references.
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class FileStorage { public class FileStorage {
@Value("${stirling.tempDir:/tmp/stirling-files}") @Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath; private String tempDirPath;
private final FileOrUploadService fileOrUploadService; private final FileOrUploadService fileOrUploadService;
/** /**
* Store a file and return its unique ID * Store a file and return its unique ID
* *
* @param file The file to store * @param file The file to store
* @return The unique ID assigned to the file * @return The unique ID assigned to the file
* @throws IOException If there is an error storing 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 { public String storeFile(MultipartFile file) throws IOException {
String fileId = generateFileId(); String fileId = generateFileId();
Path filePath = getFilePath(fileId); Path filePath = getFilePath(fileId);
// Ensure the directory exists // Ensure the directory exists
Files.createDirectories(filePath.getParent()); Files.createDirectories(filePath.getParent());
// Transfer the file to the storage location // Transfer the file to the storage location
file.transferTo(filePath.toFile()); file.transferTo(filePath.toFile());
log.debug("Stored file with ID: {}", fileId); log.debug("Stored file with ID: {}", fileId);
return fileId; return fileId;
} }
/** /**
* Store a byte array as a file and return its unique ID * Store a byte array as a file and return its unique ID
* *
* @param bytes The byte array to store * @param bytes The byte array to store
* @param originalName The original name of the file (for extension) * @param originalName The original name of the file (for extension)
* @return The unique ID assigned to the file * @return The unique ID assigned to the file
@ -58,55 +58,55 @@ public class FileStorage {
public String storeBytes(byte[] bytes, String originalName) throws IOException { public String storeBytes(byte[] bytes, String originalName) throws IOException {
String fileId = generateFileId(); String fileId = generateFileId();
Path filePath = getFilePath(fileId); Path filePath = getFilePath(fileId);
// Ensure the directory exists // Ensure the directory exists
Files.createDirectories(filePath.getParent()); Files.createDirectories(filePath.getParent());
// Write the bytes to the file // Write the bytes to the file
Files.write(filePath, bytes); Files.write(filePath, bytes);
log.debug("Stored byte array with ID: {}", fileId); log.debug("Stored byte array with ID: {}", fileId);
return fileId; return fileId;
} }
/** /**
* Retrieve a file by its ID as a MultipartFile * Retrieve a file by its ID as a MultipartFile
* *
* @param fileId The ID of the file to retrieve * @param fileId The ID of the file to retrieve
* @return The file as a MultipartFile * @return The file as a MultipartFile
* @throws IOException If the file doesn't exist or can't be read * @throws IOException If the file doesn't exist or can't be read
*/ */
public MultipartFile retrieveFile(String fileId) throws IOException { public MultipartFile retrieveFile(String fileId) throws IOException {
Path filePath = getFilePath(fileId); Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) { if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId); throw new IOException("File not found with ID: " + fileId);
} }
byte[] fileData = Files.readAllBytes(filePath); byte[] fileData = Files.readAllBytes(filePath);
return fileOrUploadService.toMockMultipartFile(fileId, fileData); return fileOrUploadService.toMockMultipartFile(fileId, fileData);
} }
/** /**
* Retrieve a file by its ID as a byte array * Retrieve a file by its ID as a byte array
* *
* @param fileId The ID of the file to retrieve * @param fileId The ID of the file to retrieve
* @return The file as a byte array * @return The file as a byte array
* @throws IOException If the file doesn't exist or can't be read * @throws IOException If the file doesn't exist or can't be read
*/ */
public byte[] retrieveBytes(String fileId) throws IOException { public byte[] retrieveBytes(String fileId) throws IOException {
Path filePath = getFilePath(fileId); Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) { if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId); throw new IOException("File not found with ID: " + fileId);
} }
return Files.readAllBytes(filePath); return Files.readAllBytes(filePath);
} }
/** /**
* Delete a file by its ID * Delete a file by its ID
* *
* @param fileId The ID of the file to delete * @param fileId The ID of the file to delete
* @return true if the file was deleted, false otherwise * @return true if the file was deleted, false otherwise
*/ */
@ -119,10 +119,10 @@ public class FileStorage {
return false; return false;
} }
} }
/** /**
* Check if a file exists by its ID * Check if a file exists by its ID
* *
* @param fileId The ID of the file to check * @param fileId The ID of the file to check
* @return true if the file exists, false otherwise * @return true if the file exists, false otherwise
*/ */
@ -130,23 +130,23 @@ public class FileStorage {
Path filePath = getFilePath(fileId); Path filePath = getFilePath(fileId);
return Files.exists(filePath); return Files.exists(filePath);
} }
/** /**
* Get the path for a file ID * Get the path for a file ID
* *
* @param fileId The ID of the file * @param fileId The ID of the file
* @return The path to the file * @return The path to the file
*/ */
private Path getFilePath(String fileId) { private Path getFilePath(String fileId) {
return Path.of(tempDirPath).resolve(fileId); return Path.of(tempDirPath).resolve(fileId);
} }
/** /**
* Generate a unique file ID * Generate a unique file ID
* *
* @return A unique file ID * @return A unique file ID
*/ */
private String generateFileId() { private String generateFileId() {
return UUID.randomUUID().toString(); return UUID.randomUUID().toString();
} }
} }

View File

@ -4,37 +4,54 @@ import java.io.IOException;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier; import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.controller.WebSocketProgressController; import stirling.software.common.controller.WebSocketProgressController;
import stirling.software.common.model.job.JobProgress; import stirling.software.common.model.job.JobProgress;
import stirling.software.common.model.job.JobResponse; import stirling.software.common.model.job.JobResponse;
/** /** Service for executing jobs asynchronously or synchronously */
* Service for executing jobs asynchronously or synchronously
*/
@Service @Service
@RequiredArgsConstructor
@Slf4j @Slf4j
public class JobExecutorService { public class JobExecutorService {
private final TaskManager taskManager; private final TaskManager taskManager;
private final WebSocketProgressController webSocketSender; private final WebSocketProgressController webSocketSender;
private final FileStorage fileStorage; 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 * Run a job either asynchronously or synchronously
* *
* @param async Whether to run the job asynchronously * @param async Whether to run the job asynchronously
* @param work The work to be done * @param work The work to be done
* @return The response * @return The response
@ -50,10 +67,22 @@ public class JobExecutorService {
executor.execute( executor.execute(
() -> { () -> {
try { 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); processJobResult(jobId, result);
webSocketSender.sendProgress( webSocketSender.sendProgress(
jobId, new JobProgress(jobId, "Done", 100, "Complete")); 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) { } catch (Exception e) {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e); log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage()); taskManager.setError(jobId, e.getMessage());
@ -65,25 +94,32 @@ public class JobExecutorService {
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null)); return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else { } else {
try { 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 the result is already a ResponseEntity, return it directly
if (result instanceof ResponseEntity) { if (result instanceof ResponseEntity) {
return (ResponseEntity<?>) result; return (ResponseEntity<?>) result;
} }
// Process different result types // Process different result types
return handleResultForSyncJob(result); 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) { } catch (Exception e) {
log.error("Error executing synchronous job: {}", e.getMessage(), e); log.error("Error executing synchronous job: {}", e.getMessage(), e);
return ResponseEntity.internalServerError().body("Job failed: " + e.getMessage()); return ResponseEntity.internalServerError().body("Job failed: " + e.getMessage());
} }
} }
} }
/** /**
* Process the result of an asynchronous job * Process the result of an asynchronous job
* *
* @param jobId The job ID * @param jobId The job ID
* @param result The result * @param result The result
*/ */
@ -97,25 +133,27 @@ public class JobExecutorService {
} else if (result instanceof ResponseEntity) { } else if (result instanceof ResponseEntity) {
ResponseEntity<?> response = (ResponseEntity<?>) result; ResponseEntity<?> response = (ResponseEntity<?>) result;
Object body = response.getBody(); Object body = response.getBody();
if (body instanceof byte[]) { if (body instanceof byte[]) {
// Extract filename from content-disposition header if available // Extract filename from content-disposition header if available
String filename = "result.pdf"; String filename = "result.pdf";
String contentType = "application/pdf"; String contentType = "application/pdf";
if (response.getHeaders().getContentDisposition() != null) { if (response.getHeaders().getContentDisposition() != null) {
String disposition = response.getHeaders().getContentDisposition().toString(); String disposition =
response.getHeaders().getContentDisposition().toString();
if (disposition.contains("filename=")) { if (disposition.contains("filename=")) {
filename = disposition.substring( filename =
disposition.indexOf("filename=") + 9, disposition.substring(
disposition.lastIndexOf("\"")); disposition.indexOf("filename=") + 9,
disposition.lastIndexOf("\""));
} }
} }
if (response.getHeaders().getContentType() != null) { if (response.getHeaders().getContentType() != null) {
contentType = response.getHeaders().getContentType().toString(); contentType = response.getHeaders().getContentType().toString();
} }
String fileId = fileStorage.storeBytes((byte[]) body, filename); String fileId = fileStorage.storeBytes((byte[]) body, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType); taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored ResponseEntity<byte[]> result with fileId: {}", fileId); log.debug("Stored ResponseEntity<byte[]> result with fileId: {}", fileId);
@ -124,26 +162,30 @@ public class JobExecutorService {
if (body != null && body.toString().contains("fileId")) { if (body != null && body.toString().contains("fileId")) {
try { try {
// Try to extract fileId using reflection // 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); String fileId = (String) getFileId.invoke(body);
if (fileId != null && !fileId.isEmpty()) { if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type // Try to get filename and content type
String filename = "result.pdf"; String filename = "result.pdf";
String contentType = "application/pdf"; String contentType = "application/pdf";
try { 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); String origName = (String) getOriginalFileName.invoke(body);
if (origName != null && !origName.isEmpty()) { if (origName != null && !origName.isEmpty()) {
filename = origName; filename = origName;
} }
} catch (Exception e) { } catch (Exception e) {
log.debug("Could not get original filename: {}", e.getMessage()); log.debug(
"Could not get original filename: {}", e.getMessage());
} }
try { 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); String ct = (String) getContentType.invoke(body);
if (ct != null && !ct.isEmpty()) { if (ct != null && !ct.isEmpty()) {
contentType = ct; contentType = ct;
@ -151,18 +193,20 @@ public class JobExecutorService {
} catch (Exception e) { } catch (Exception e) {
log.debug("Could not get content type: {}", e.getMessage()); log.debug("Could not get content type: {}", e.getMessage());
} }
taskManager.setFileResult(jobId, fileId, filename, contentType); taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Extracted fileId from response body: {}", fileId); log.debug("Extracted fileId from response body: {}", fileId);
taskManager.setComplete(jobId); taskManager.setComplete(jobId);
return; return;
} }
} catch (Exception e) { } 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 // Store generic result
taskManager.setResult(jobId, body); taskManager.setResult(jobId, body);
} }
@ -170,26 +214,25 @@ public class JobExecutorService {
MultipartFile file = (MultipartFile) result; MultipartFile file = (MultipartFile) result;
String fileId = fileStorage.storeFile(file); String fileId = fileStorage.storeFile(file);
taskManager.setFileResult( taskManager.setFileResult(
jobId, jobId, fileId, file.getOriginalFilename(), file.getContentType());
fileId,
file.getOriginalFilename(),
file.getContentType());
log.debug("Stored MultipartFile result with fileId: {}", fileId); log.debug("Stored MultipartFile result with fileId: {}", fileId);
} else { } else {
// Check if result has a fileId field // Check if result has a fileId field
if (result != null) { if (result != null) {
try { try {
// Try to extract fileId using reflection // 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); String fileId = (String) getFileId.invoke(result);
if (fileId != null && !fileId.isEmpty()) { if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type // Try to get filename and content type
String filename = "result.pdf"; String filename = "result.pdf";
String contentType = "application/pdf"; String contentType = "application/pdf";
try { 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); String origName = (String) getOriginalFileName.invoke(result);
if (origName != null && !origName.isEmpty()) { if (origName != null && !origName.isEmpty()) {
filename = origName; filename = origName;
@ -197,9 +240,10 @@ public class JobExecutorService {
} catch (Exception e) { } catch (Exception e) {
log.debug("Could not get original filename: {}", e.getMessage()); log.debug("Could not get original filename: {}", e.getMessage());
} }
try { 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); String ct = (String) getContentType.invoke(result);
if (ct != null && !ct.isEmpty()) { if (ct != null && !ct.isEmpty()) {
contentType = ct; contentType = ct;
@ -207,32 +251,33 @@ public class JobExecutorService {
} catch (Exception e) { } catch (Exception e) {
log.debug("Could not get content type: {}", e.getMessage()); log.debug("Could not get content type: {}", e.getMessage());
} }
taskManager.setFileResult(jobId, fileId, filename, contentType); taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Extracted fileId from result object: {}", fileId); log.debug("Extracted fileId from result object: {}", fileId);
taskManager.setComplete(jobId); taskManager.setComplete(jobId);
return; return;
} }
} catch (Exception e) { } 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 // Default case: store the result as is
taskManager.setResult(jobId, result); taskManager.setResult(jobId, result);
} }
taskManager.setComplete(jobId); taskManager.setComplete(jobId);
} catch (Exception e) { } catch (Exception e) {
log.error("Error processing job result: {}", e.getMessage(), e); log.error("Error processing job result: {}", e.getMessage(), e);
taskManager.setError(jobId, "Error processing result: " + e.getMessage()); taskManager.setError(jobId, "Error processing result: " + e.getMessage());
} }
} }
/** /**
* Handle different result types for synchronous jobs * Handle different result types for synchronous jobs
* *
* @param result The result object * @param result The result object
* @return The appropriate ResponseEntity * @return The appropriate ResponseEntity
* @throws IOException If there is an error processing the result * @throws IOException If there is an error processing the result
@ -242,7 +287,8 @@ public class JobExecutorService {
// Return byte array as PDF // Return byte array as PDF
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF) .contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, .header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\"result.pdf\"") "form-data; name=\"attachment\"; filename=\"result.pdf\"")
.body(result); .body(result);
} else if (result instanceof MultipartFile) { } else if (result instanceof MultipartFile) {
@ -250,13 +296,74 @@ public class JobExecutorService {
MultipartFile file = (MultipartFile) result; MultipartFile file = (MultipartFile) result;
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.getContentType())) .contentType(MediaType.parseMediaType(file.getContentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, .header(
"form-data; name=\"attachment\"; filename=\"" + HttpHeaders.CONTENT_DISPOSITION,
file.getOriginalFilename() + "\"") "form-data; name=\"attachment\"; filename=\""
+ file.getOriginalFilename()
+ "\"")
.body(file.getBytes()); .body(file.getBytes());
} else { } else {
// Default case: return as JSON // Default case: return as JSON
return ResponseEntity.ok(result); 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> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
throws TimeoutException, Exception {
java.util.concurrent.CompletableFuture<T> 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);
}
}
} }

View File

@ -1,47 +1,65 @@
package stirling.software.common.service; package stirling.software.common.service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; 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.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; 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 lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult; 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 @Service
@RequiredArgsConstructor
@Slf4j @Slf4j
public class TaskManager { public class TaskManager {
private final Map<String, JobResult> jobResults = new ConcurrentHashMap<>(); private final Map<String, JobResult> jobResults = new ConcurrentHashMap<>();
@Value("${stirling.jobResultExpiryMinutes:30}") @Value("${stirling.jobResultExpiryMinutes:30}")
private int jobResultExpiryMinutes = 30; private int jobResultExpiryMinutes = 30;
private final FileStorage fileStorage; 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 * Create a new task with the given job ID
* *
* @param jobId The job ID * @param jobId The job ID
*/ */
public void createTask(String jobId) { public void createTask(String jobId) {
jobResults.put(jobId, JobResult.createNew(jobId)); jobResults.put(jobId, JobResult.createNew(jobId));
log.debug("Created task with job ID: {}", jobId); log.debug("Created task with job ID: {}", jobId);
} }
/** /**
* Set the result of a task as a general object * Set the result of a task as a general object
* *
* @param jobId The job ID * @param jobId The job ID
* @param result The result object * @param result The result object
*/ */
@ -50,24 +68,25 @@ public class TaskManager {
jobResult.completeWithResult(result); jobResult.completeWithResult(result);
log.debug("Set result for job ID: {}", jobId); log.debug("Set result for job ID: {}", jobId);
} }
/** /**
* Set the result of a task as a file * Set the result of a task as a file
* *
* @param jobId The job ID * @param jobId The job ID
* @param fileId The file ID * @param fileId The file ID
* @param originalFileName The original file name * @param originalFileName The original file name
* @param contentType The content type of the file * @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 jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFile(fileId, originalFileName, contentType); jobResult.completeWithFile(fileId, originalFileName, contentType);
log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId); log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId);
} }
/** /**
* Set an error for a task * Set an error for a task
* *
* @param jobId The job ID * @param jobId The job ID
* @param error The error message * @param error The error message
*/ */
@ -76,24 +95,26 @@ public class TaskManager {
jobResult.failWithError(error); jobResult.failWithError(error);
log.debug("Set error for job ID: {}: {}", jobId, error); log.debug("Set error for job ID: {}: {}", jobId, error);
} }
/** /**
* Mark a task as complete * Mark a task as complete
* *
* @param jobId The job ID * @param jobId The job ID
*/ */
public void setComplete(String jobId) { public void setComplete(String jobId) {
JobResult jobResult = getOrCreateJobResult(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 // If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully"); jobResult.completeWithResult("Task completed successfully");
} }
log.debug("Marked job ID: {} as complete", jobId); log.debug("Marked job ID: {} as complete", jobId);
} }
/** /**
* Check if a task is complete * Check if a task is complete
* *
* @param jobId The job ID * @param jobId The job ID
* @return true if the task is complete, false otherwise * @return true if the task is complete, false otherwise
*/ */
@ -101,86 +122,153 @@ public class TaskManager {
JobResult result = jobResults.get(jobId); JobResult result = jobResults.get(jobId);
return result != null && result.isComplete(); return result != null && result.isComplete();
} }
/** /**
* Get the result of a task * Get the result of a task
* *
* @param jobId The job ID * @param jobId The job ID
* @return The result object, or null if the task doesn't exist or is not complete * @return The result object, or null if the task doesn't exist or is not complete
*/ */
public JobResult getJobResult(String jobId) { public JobResult getJobResult(String jobId) {
return jobResults.get(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 * Get or create a job result
* *
* @param jobId The job ID * @param jobId The job ID
* @return The job result * @return The job result
*/ */
private JobResult getOrCreateJobResult(String jobId) { private JobResult getOrCreateJobResult(String jobId) {
return jobResults.computeIfAbsent(jobId, JobResult::createNew); return jobResults.computeIfAbsent(jobId, JobResult::createNew);
} }
/** /** Clean up old completed job results */
* REST controller for job-related endpoints public void cleanupOldJobs() {
*/ LocalDateTime expiryThreshold =
@RestController LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
public class JobController { int removedCount = 0;
/** try {
* Get the status of a job for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
* JobResult result = entry.getValue();
* @param jobId The job ID
* @return The job result // Remove completed jobs that are older than the expiry threshold
*/ if (result.isComplete()
@GetMapping("/api/v1/general/job/{jobId}") && result.getCompletedAt() != null
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) { && result.getCompletedAt().isBefore(expiryThreshold)) {
JobResult result = jobResults.get(jobId);
if (result == null) { // If the job has a file result, delete the file
return ResponseEntity.notFound().build(); if (result.getFileId() != null) {
} try {
return ResponseEntity.ok(result); fileStorage.deleteFile(result.getFileId());
} } catch (Exception e) {
log.warn(
/** "Failed to delete file for job {}: {}",
* Get the result of a job entry.getKey(),
* e.getMessage());
* @param jobId The job ID }
* @return The job result }
*/
@GetMapping("/api/v1/general/job/{jobId}/result") // Remove the job result
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) { jobResults.remove(entry.getKey());
JobResult result = jobResults.get(jobId); removedCount++;
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()); 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();
} }
} }
} }

View File

@ -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<JobStats> 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<String, Object> responseBody = (Map<String, Object>) response.getBody();
assertEquals("Cleanup complete", responseBody.get("message"));
assertEquals(3, responseBody.get("removedJobs"));
assertEquals(7, responseBody.get("remainingJobs"));
verify(taskManager).cleanupOldJobs();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<String, JobResult> jobResultsMap = (Map<String, JobResult>) 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
}
}