Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

406 lines
14 KiB
Java
Raw Normal View History

Async (#3773) # Description of Changes This pull request introduces a job management system with enhanced capabilities for handling asynchronous tasks, file operations, and progress tracking. Key changes include the addition of new annotations and aspects for job execution, file management services, and models for job progress and results. ### Job Execution Enhancements: * [`common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java`](diffhunk://#diff-570304f67b974d5bd30a28d05d34759b86bcb4a35148d779e2b46904e8dd2904R1-R47): Added a custom annotation to simplify job handling for POST requests, including support for retries, progress tracking, and resource management. * [`common/src/main/java/stirling/software/common/aop/AutoJobAspect.java`](diffhunk://#diff-5f725b1d99dbc47dfe9b1d07f37382ca7c81d587725dc35a62c644d1a25f9869R1-R231): Implemented an aspect to integrate job execution logic, handling retries, asynchronous processing, and file management seamlessly. ### File Management: * [`common/src/main/java/stirling/software/common/service/FileStorage.java`](diffhunk://#diff-f382e12c197ad6f7c5b01b1cea912e9b141a4b4e4ab7f12baafa1b69cb112962R1-R152): Added a service for storing, retrieving, and managing files using unique IDs, enabling persistent file handling for jobs. * [`common/src/main/java/stirling/software/common/service/FileOrUploadService.java`](diffhunk://#diff-e0637404eea2b1c1413cf5f3247208a9196b14388a90a896314d3e9c2949c893R1-R78): Added utility methods for converting files to `MultipartFile` and resolving file paths. ### Job Models: * [`common/src/main/java/stirling/software/common/model/job/JobProgress.java`](diffhunk://#diff-edc765f0e32ef4cb5a03dd3badafad450336a5248221ecc27976eb692280f003R1-R15): Introduced a model to represent job progress, including completion percentage and status messages. * [`common/src/main/java/stirling/software/common/model/job/JobResult.java`](diffhunk://#diff-b34316aa0ebfd849f41086339ae0323cb5cc2066b8200c38c6a39564e17b88f3R1-R94): Added a model to encapsulate job results, supporting both file-based and object-based outcomes. * [`common/src/main/java/stirling/software/common/model/job/JobResponse.java`](diffhunk://#diff-b02e9f86d44beda10ceb66650c79d1e032acd6f6a609887fb5f5596713048ab1R1-R14): Created a model for job responses, including async execution details and job IDs. * [`common/src/main/java/stirling/software/common/model/job/JobStats.java`](diffhunk://#diff-6067e6bd9e44d9dc40419d2435fa24d6753ec51e3baf7967dbcbc1a51e95e8afR1-R43): Added a model for tracking job statistics, such as total jobs, success rates, and average processing times. ### Other Changes: * [`common/src/main/java/stirling/software/common/model/api/PDFFile.java`](diffhunk://#diff-d2419d05a852acf8f8d0bd5c3673bbdd8e385b2d5cf1d80fbd8b66691ebd2cb2L17-R24): Updated the `PDFFile` model to include a `fileId` field for server-side file references, enhancing flexibility in file handling. * [`common/build.gradle`](diffhunk://#diff-824c1e8ad11e20caed0bec7162a99779b9a4bcf1178d99fae3e39f69889f8959R31): Added the `spring-boot-starter-aop` dependency to enable aspect-oriented programming. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: a <a>
2025-06-23 13:11:44 +01:00
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 org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
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.JobQueue;
import stirling.software.common.service.TaskManager;
class JobControllerTest {
@Mock
private TaskManager taskManager;
@Mock
private FileStorage fileStorage;
@Mock
private JobQueue jobQueue;
@Mock
private HttpServletRequest request;
private MockHttpSession session;
@InjectMocks
private JobController controller;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
// Setup mock session for tests
session = new MockHttpSession();
when(request.getSession()).thenReturn(session);
}
@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_ExistingJobInQueue() {
// Arrange
String jobId = "test-job-id";
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.setComplete(false);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
when(jobQueue.isJobQueued(jobId)).thenReturn(true);
when(jobQueue.getJobPosition(jobId)).thenReturn(3);
// Act
ResponseEntity<?> response = controller.getJobStatus(jobId);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
assertEquals(mockResult, responseBody.get("jobResult"));
@SuppressWarnings("unchecked")
Map<String, Object> queueInfo = (Map<String, Object>) responseBody.get("queueInfo");
assertTrue((Boolean) queueInfo.get("inQueue"));
assertEquals(3, queueInfo.get("position"));
}
@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<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(); }
*
* @Test void testGetQueueStats() { // Arrange Map<String, Object>
* mockQueueStats = Map.of( "queuedJobs", 5, "queueCapacity", 10,
* "resourceStatus", "OK" );
*
* when(jobQueue.getQueueStats()).thenReturn(mockQueueStats);
*
* // Act ResponseEntity<?> response = controller.getQueueStats();
*
* // Assert assertEquals(HttpStatus.OK, response.getStatusCode());
* assertEquals(mockQueueStats, response.getBody());
* verify(jobQueue).getQueueStats(); }
*/
@Test
void testCancelJob_InQueue() {
// Arrange
String jobId = "job-in-queue";
// Setup user session with job authorization
java.util.Set<String> userJobIds = new java.util.HashSet<>();
userJobIds.add(jobId);
session.setAttribute("userJobIds", userJobIds);
when(jobQueue.isJobQueued(jobId)).thenReturn(true);
when(jobQueue.getJobPosition(jobId)).thenReturn(2);
when(jobQueue.cancelJob(jobId)).thenReturn(true);
// Act
ResponseEntity<?> response = controller.cancelJob(jobId);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
assertEquals("Job cancelled successfully", responseBody.get("message"));
assertTrue((Boolean) responseBody.get("wasQueued"));
assertEquals(2, responseBody.get("queuePosition"));
verify(jobQueue).cancelJob(jobId);
verify(taskManager, never()).setError(anyString(), anyString());
}
@Test
void testCancelJob_Running() {
// Arrange
String jobId = "job-running";
JobResult jobResult = new JobResult();
jobResult.setJobId(jobId);
jobResult.setComplete(false);
// Setup user session with job authorization
java.util.Set<String> userJobIds = new java.util.HashSet<>();
userJobIds.add(jobId);
session.setAttribute("userJobIds", userJobIds);
when(jobQueue.isJobQueued(jobId)).thenReturn(false);
when(taskManager.getJobResult(jobId)).thenReturn(jobResult);
// Act
ResponseEntity<?> response = controller.cancelJob(jobId);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
assertEquals("Job cancelled successfully", responseBody.get("message"));
assertFalse((Boolean) responseBody.get("wasQueued"));
assertEquals("n/a", responseBody.get("queuePosition"));
verify(jobQueue, never()).cancelJob(jobId);
verify(taskManager).setError(jobId, "Job was cancelled by user");
}
@Test
void testCancelJob_NotFound() {
// Arrange
String jobId = "non-existent-job";
// Setup user session with job authorization
java.util.Set<String> userJobIds = new java.util.HashSet<>();
userJobIds.add(jobId);
session.setAttribute("userJobIds", userJobIds);
when(jobQueue.isJobQueued(jobId)).thenReturn(false);
when(taskManager.getJobResult(jobId)).thenReturn(null);
// Act
ResponseEntity<?> response = controller.cancelJob(jobId);
// Assert
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}
@Test
void testCancelJob_AlreadyComplete() {
// Arrange
String jobId = "completed-job";
JobResult jobResult = new JobResult();
jobResult.setJobId(jobId);
jobResult.setComplete(true);
// Setup user session with job authorization
java.util.Set<String> userJobIds = new java.util.HashSet<>();
userJobIds.add(jobId);
session.setAttribute("userJobIds", userJobIds);
when(jobQueue.isJobQueued(jobId)).thenReturn(false);
when(taskManager.getJobResult(jobId)).thenReturn(jobResult);
// Act
ResponseEntity<?> response = controller.cancelJob(jobId);
// Assert
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
assertEquals("Cannot cancel job that is already complete", responseBody.get("message"));
}
@Test
void testCancelJob_Unauthorized() {
// Arrange
String jobId = "unauthorized-job";
// Setup user session with other job IDs but not this one
java.util.Set<String> userJobIds = new java.util.HashSet<>();
userJobIds.add("other-job-1");
userJobIds.add("other-job-2");
session.setAttribute("userJobIds", userJobIds);
// Act
ResponseEntity<?> response = controller.cancelJob(jobId);
// Assert
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
assertEquals("You are not authorized to cancel this job", responseBody.get("message"));
// Verify no cancellation attempts were made
verify(jobQueue, never()).isJobQueued(anyString());
verify(jobQueue, never()).cancelJob(anyString());
verify(taskManager, never()).getJobResult(anyString());
verify(taskManager, never()).setError(anyString(), anyString());
}
}