mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
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 <[email protected]> Co-authored-by: a <a>
This commit is contained in:
co-authored by
Copilot
a <a>
parent
ee8030c1c4
commit
7d7f1272e4
+406
@@ -0,0 +1,406 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user