From 45e18b2611ddd7c3c7ec6901b20d1758ae505703 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com.> Date: Thu, 19 Jun 2025 11:22:39 +0100 Subject: [PATCH] tests --- .../AutoJobPostMappingIntegrationTest.java | 208 ++++++++++ .../common/service/FileStorageTest.java | 190 +++++++++ .../service/JobExecutorServiceTest.java | 202 ++++++++++ .../software/common/service/JobQueueTest.java | 102 +++++ .../common/service/ResourceMonitorTest.java | 137 +++++++ .../common/service/TaskManagerTest.java | 255 ++++++++++++ .../common/controller/JobController.java | 82 ++++ .../common/controller/JobControllerTest.java | 365 ++++++++++++++++++ 8 files changed, 1541 insertions(+) create mode 100644 common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java create mode 100644 common/src/test/java/stirling/software/common/service/FileStorageTest.java create mode 100644 common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java create mode 100644 common/src/test/java/stirling/software/common/service/JobQueueTest.java create mode 100644 common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java create mode 100644 common/src/test/java/stirling/software/common/service/TaskManagerTest.java create mode 100644 stirling-pdf/src/test/java/stirling/software/common/controller/JobControllerTest.java diff --git a/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java b/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java new file mode 100644 index 000000000..e25ceddf9 --- /dev/null +++ b/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java @@ -0,0 +1,208 @@ +package stirling.software.common.annotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; + +import java.util.Arrays; +import java.util.function.Supplier; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.common.aop.AutoJobAspect; +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.service.FileOrUploadService; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.JobExecutorService; +import stirling.software.common.service.JobQueue; +import stirling.software.common.service.ResourceMonitor; + +@ExtendWith(MockitoExtension.class) +class AutoJobPostMappingIntegrationTest { + + private AutoJobAspect autoJobAspect; + + @Mock + private JobExecutorService jobExecutorService; + + @Mock + private HttpServletRequest request; + + @Mock + private FileOrUploadService fileOrUploadService; + + @Mock + private FileStorage fileStorage; + + + @Mock + private ResourceMonitor resourceMonitor; + + @Mock + private JobQueue jobQueue; + + @BeforeEach + void setUp() { + autoJobAspect = new AutoJobAspect( + jobExecutorService, + request, + fileOrUploadService, + fileStorage + ); + } + + @Mock + private ProceedingJoinPoint joinPoint; + + @Mock + private AutoJobPostMapping autoJobPostMapping; + + @Captor + private ArgumentCaptor> workCaptor; + + @Captor + private ArgumentCaptor asyncCaptor; + + @Captor + private ArgumentCaptor timeoutCaptor; + + @Captor + private ArgumentCaptor queueableCaptor; + + @Captor + private ArgumentCaptor resourceWeightCaptor; + + @Test + void shouldExecuteWithCustomParameters() throws Throwable { + // Given + PDFFile pdfFile = new PDFFile(); + pdfFile.setFileId("test-file-id"); + Object[] args = new Object[] { pdfFile }; + + when(joinPoint.getArgs()).thenReturn(args); + when(request.getParameter("async")).thenReturn("true"); + when(autoJobPostMapping.timeout()).thenReturn(60000L); + when(autoJobPostMapping.retryCount()).thenReturn(3); + when(autoJobPostMapping.trackProgress()).thenReturn(true); + when(autoJobPostMapping.queueable()).thenReturn(true); + when(autoJobPostMapping.resourceWeight()).thenReturn(75); + + MultipartFile mockFile = mock(MultipartFile.class); + when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile); + + + when(jobExecutorService.runJobGeneric( + anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) + .thenReturn(ResponseEntity.ok("success")); + + // When + Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping); + + // Then + assertEquals(ResponseEntity.ok("success"), result); + + verify(jobExecutorService).runJobGeneric( + asyncCaptor.capture(), + workCaptor.capture(), + timeoutCaptor.capture(), + queueableCaptor.capture(), + resourceWeightCaptor.capture()); + + assertTrue(asyncCaptor.getValue(), "Async should be true"); + assertEquals(60000L, timeoutCaptor.getValue(), "Timeout should be 60000ms"); + assertTrue(queueableCaptor.getValue(), "Queueable should be true"); + assertEquals(75, resourceWeightCaptor.getValue(), "Resource weight should be 75"); + + // Test that file was resolved + assertNotNull(pdfFile.getFileInput(), "File input should be set"); + } + + @Test + void shouldRetryOnError() throws Throwable { + // Given + when(joinPoint.getArgs()).thenReturn(new Object[0]); + when(request.getParameter("async")).thenReturn("false"); + when(autoJobPostMapping.timeout()).thenReturn(-1L); + when(autoJobPostMapping.retryCount()).thenReturn(2); + when(autoJobPostMapping.trackProgress()).thenReturn(false); + when(autoJobPostMapping.queueable()).thenReturn(false); + when(autoJobPostMapping.resourceWeight()).thenReturn(50); + + // First call throws exception, second succeeds + when(joinPoint.proceed(any())) + .thenThrow(new RuntimeException("First attempt failed")) + .thenReturn(ResponseEntity.ok("retry succeeded")); + + // Mock jobExecutorService to execute the work immediately + when(jobExecutorService.runJobGeneric( + anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) + .thenAnswer(invocation -> { + Supplier work = invocation.getArgument(1); + return work.get(); + }); + + // When + Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping); + + // Then + assertEquals(ResponseEntity.ok("retry succeeded"), result); + + // Verify that proceed was called twice (initial attempt + 1 retry) + verify(joinPoint, times(2)).proceed(any()); + } + + @Test + void shouldHandlePDFFileWithAsyncRequests() throws Throwable { + // Given + PDFFile pdfFile = new PDFFile(); + pdfFile.setFileInput(mock(MultipartFile.class)); + Object[] args = new Object[] { pdfFile }; + + when(joinPoint.getArgs()).thenReturn(args); + when(request.getParameter("async")).thenReturn("true"); + when(autoJobPostMapping.retryCount()).thenReturn(1); + + when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("stored-file-id"); + when(fileStorage.retrieveFile("stored-file-id")).thenReturn(mock(MultipartFile.class)); + + // Mock job executor to return a successful response + when(jobExecutorService.runJobGeneric( + anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) + .thenReturn(ResponseEntity.ok("success")); + + // When + autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping); + + // Then + assertEquals("stored-file-id", pdfFile.getFileId(), + "FileId should be set to the stored file id"); + assertNotNull(pdfFile.getFileInput(), "FileInput should be replaced with persistent file"); + + // Verify storage operations + verify(fileStorage).storeFile(any(MultipartFile.class)); + verify(fileStorage).retrieveFile("stored-file-id"); + } +} diff --git a/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/common/src/test/java/stirling/software/common/service/FileStorageTest.java new file mode 100644 index 000000000..f1ca1ffdf --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -0,0 +1,190 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.AdditionalAnswers.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +class FileStorageTest { + + @TempDir + Path tempDir; + + @Mock + private FileOrUploadService fileOrUploadService; + + @InjectMocks + private FileStorage fileStorage; + + private MultipartFile mockFile; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString()); + + // Create a mock MultipartFile + mockFile = mock(MultipartFile.class); + when(mockFile.getOriginalFilename()).thenReturn("test.pdf"); + when(mockFile.getContentType()).thenReturn("application/pdf"); + } + + @Test + void testStoreFile() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + when(mockFile.getBytes()).thenReturn(fileContent); + + // Set up mock to handle transferTo by writing the file + doAnswer(invocation -> { + java.io.File file = invocation.getArgument(0); + Files.write(file.toPath(), fileContent); + return null; + }).when(mockFile).transferTo(any(java.io.File.class)); + + // Act + String fileId = fileStorage.storeFile(mockFile); + + // Assert + assertNotNull(fileId); + assertTrue(Files.exists(tempDir.resolve(fileId))); + verify(mockFile).transferTo(any(java.io.File.class)); + } + + @Test + void testStoreBytes() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String originalName = "test.pdf"; + + // Act + String fileId = fileStorage.storeBytes(fileContent, originalName); + + // Assert + assertNotNull(fileId); + assertTrue(Files.exists(tempDir.resolve(fileId))); + assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId))); + } + + @Test + void testRetrieveFile() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String fileId = UUID.randomUUID().toString(); + Path filePath = tempDir.resolve(fileId); + Files.write(filePath, fileContent); + + MultipartFile expectedFile = mock(MultipartFile.class); + when(fileOrUploadService.toMockMultipartFile(eq(fileId), eq(fileContent))) + .thenReturn(expectedFile); + + // Act + MultipartFile result = fileStorage.retrieveFile(fileId); + + // Assert + assertSame(expectedFile, result); + verify(fileOrUploadService).toMockMultipartFile(eq(fileId), eq(fileContent)); + } + + @Test + void testRetrieveBytes() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String fileId = UUID.randomUUID().toString(); + Path filePath = tempDir.resolve(fileId); + Files.write(filePath, fileContent); + + // Act + byte[] result = fileStorage.retrieveBytes(fileId); + + // Assert + assertArrayEquals(fileContent, result); + } + + @Test + void testRetrieveFile_FileNotFound() { + // Arrange + String nonExistentFileId = "non-existent-file"; + + // Act & Assert + assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId)); + } + + @Test + void testRetrieveBytes_FileNotFound() { + // Arrange + String nonExistentFileId = "non-existent-file"; + + // Act & Assert + assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId)); + } + + @Test + void testDeleteFile() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String fileId = UUID.randomUUID().toString(); + Path filePath = tempDir.resolve(fileId); + Files.write(filePath, fileContent); + + // Act + boolean result = fileStorage.deleteFile(fileId); + + // Assert + assertTrue(result); + assertFalse(Files.exists(filePath)); + } + + @Test + void testDeleteFile_FileNotFound() { + // Arrange + String nonExistentFileId = "non-existent-file"; + + // Act + boolean result = fileStorage.deleteFile(nonExistentFileId); + + // Assert + assertFalse(result); + } + + @Test + void testFileExists() throws IOException { + // Arrange + byte[] fileContent = "Test PDF content".getBytes(); + String fileId = UUID.randomUUID().toString(); + Path filePath = tempDir.resolve(fileId); + Files.write(filePath, fileContent); + + // Act + boolean result = fileStorage.fileExists(fileId); + + // Assert + assertTrue(result); + } + + @Test + void testFileExists_FileNotFound() { + // Arrange + String nonExistentFileId = "non-existent-file"; + + // Act + boolean result = fileStorage.fileExists(nonExistentFileId); + + // Assert + assertFalse(result); + } +} \ No newline at end of file diff --git a/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java b/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java new file mode 100644 index 000000000..a4d293b1b --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java @@ -0,0 +1,202 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.common.model.job.JobProgress; +import stirling.software.common.model.job.JobResponse; + +@ExtendWith(MockitoExtension.class) +class JobExecutorServiceTest { + + private JobExecutorService jobExecutorService; + + @Mock + private TaskManager taskManager; + + @Mock + private FileStorage fileStorage; + + @Mock + private HttpServletRequest request; + + @Mock + private ResourceMonitor resourceMonitor; + + @Mock + private JobQueue jobQueue; + + @Captor + private ArgumentCaptor jobIdCaptor; + + @BeforeEach + void setUp() { + // Initialize the service manually with all its dependencies + jobExecutorService = new JobExecutorService( + taskManager, + fileStorage, + request, + resourceMonitor, + jobQueue, + 30000L, // asyncRequestTimeoutMs + "30m" // sessionTimeout + ); + } + + @Test + void shouldRunSyncJobSuccessfully() throws Exception { + // Given + Supplier work = () -> "test-result"; + + // When + ResponseEntity response = jobExecutorService.runJobGeneric(false, work); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("test-result", response.getBody()); + + // Verify request attribute was set with jobId + verify(request).setAttribute(eq("jobId"), anyString()); + } + + @Test + void shouldRunAsyncJobSuccessfully() throws Exception { + // Given + Supplier work = () -> "test-result"; + + // When + ResponseEntity response = jobExecutorService.runJobGeneric(true, work); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody() instanceof JobResponse); + JobResponse jobResponse = (JobResponse) response.getBody(); + assertTrue(jobResponse.isAsync()); + assertNotNull(jobResponse.getJobId()); + + // Verify task manager was called + verify(taskManager).createTask(jobIdCaptor.capture()); + } + + + @Test + void shouldHandleSyncJobError() { + // Given + Supplier work = () -> { + throw new RuntimeException("Test error"); + }; + + // When + ResponseEntity response = jobExecutorService.runJobGeneric(false, work); + + // Then + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + + @SuppressWarnings("unchecked") + Map errorMap = (Map) response.getBody(); + assertEquals("Job failed: Test error", errorMap.get("error")); + } + + @Test + void shouldQueueJobWhenResourcesLimited() { + // Given + Supplier work = () -> "test-result"; + CompletableFuture> future = new CompletableFuture<>(); + + // Configure resourceMonitor to indicate job should be queued + when(resourceMonitor.shouldQueueJob(80)).thenReturn(true); + + // Configure jobQueue to return our future + when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future); + + // When + ResponseEntity response = jobExecutorService.runJobGeneric( + true, work, 5000, true, 80); + + // Then + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody() instanceof JobResponse); + + // Verify job was queued + verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L)); + verify(taskManager).createTask(anyString()); + } + + @Test + void shouldUseCustomTimeoutWhenProvided() throws Exception { + // Given + Supplier work = () -> "test-result"; + long customTimeout = 60000L; + + // Use reflection to access the private executeWithTimeout method + java.lang.reflect.Method executeMethod = JobExecutorService.class + .getDeclaredMethod("executeWithTimeout", Supplier.class, long.class); + executeMethod.setAccessible(true); + + // Create a spy on the JobExecutorService to verify method calls + JobExecutorService spy = Mockito.spy(jobExecutorService); + + // When + spy.runJobGeneric(false, work, customTimeout); + + // Then + verify(spy).runJobGeneric(eq(false), any(Supplier.class), eq(customTimeout)); + } + + @Test + void shouldHandleTimeout() throws Exception { + // Given + Supplier work = () -> { + try { + Thread.sleep(100); // Simulate long-running job + return "test-result"; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + }; + + // Use reflection to access the private executeWithTimeout method + java.lang.reflect.Method executeMethod = JobExecutorService.class + .getDeclaredMethod("executeWithTimeout", Supplier.class, long.class); + executeMethod.setAccessible(true); + + // When/Then + try { + executeMethod.invoke(jobExecutorService, work, 1L); // Very short timeout + } catch (Exception e) { + assertTrue(e.getCause() instanceof TimeoutException); + } + } +} \ No newline at end of file diff --git a/common/src/test/java/stirling/software/common/service/JobQueueTest.java b/common/src/test/java/stirling/software/common/service/JobQueueTest.java new file mode 100644 index 000000000..813f5e172 --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/JobQueueTest.java @@ -0,0 +1,102 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.job.JobProgress; +import stirling.software.common.service.ResourceMonitor.ResourceStatus; + +@ExtendWith(MockitoExtension.class) +class JobQueueTest { + + private JobQueue jobQueue; + + @Mock + private ResourceMonitor resourceMonitor; + + + private final AtomicReference statusRef = new AtomicReference<>(ResourceStatus.OK); + + @BeforeEach + void setUp() { + // Mark stubbing as lenient to avoid UnnecessaryStubbingException + lenient().when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(10); + lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef); + + // Initialize JobQueue with mocked ResourceMonitor + jobQueue = new JobQueue(resourceMonitor); + } + + @Test + void shouldQueueJob() { + String jobId = "test-job-1"; + int resourceWeight = 50; + Supplier work = () -> "test-result"; + long timeoutMs = 1000; + + jobQueue.queueJob(jobId, resourceWeight, work, timeoutMs); + + + assertTrue(jobQueue.isJobQueued(jobId)); + assertEquals(1, jobQueue.getTotalQueuedJobs()); + } + + @Test + void shouldCancelJob() { + String jobId = "test-job-2"; + Supplier work = () -> "test-result"; + + jobQueue.queueJob(jobId, 50, work, 1000); + boolean cancelled = jobQueue.cancelJob(jobId); + + assertTrue(cancelled); + assertFalse(jobQueue.isJobQueued(jobId)); + } + + @Test + void shouldGetQueueStats() { + when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef); + + jobQueue.queueJob("job1", 50, () -> "ok", 1000); + jobQueue.queueJob("job2", 50, () -> "ok", 1000); + jobQueue.cancelJob("job2"); + + Map stats = jobQueue.getQueueStats(); + + assertEquals(2, stats.get("totalQueuedJobs")); + assertTrue(stats.containsKey("queuedJobs")); + assertTrue(stats.containsKey("resourceStatus")); + } + + @Test + void shouldCalculateQueueCapacity() { + when(resourceMonitor.calculateDynamicQueueCapacity(5, 2)).thenReturn(8); + int capacity = resourceMonitor.calculateDynamicQueueCapacity(5, 2); + assertEquals(8, capacity); + } + + @Test + void shouldCheckIfJobIsQueued() { + String jobId = "job-123"; + Supplier work = () -> "hello"; + + jobQueue.queueJob(jobId, 40, work, 500); + + assertTrue(jobQueue.isJobQueued(jobId)); + assertFalse(jobQueue.isJobQueued("nonexistent")); + } +} \ No newline at end of file diff --git a/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java b/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java new file mode 100644 index 000000000..a707b87e6 --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java @@ -0,0 +1,137 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.lang.management.OperatingSystemMXBean; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.service.ResourceMonitor.ResourceMetrics; +import stirling.software.common.service.ResourceMonitor.ResourceStatus; + +@ExtendWith(MockitoExtension.class) +class ResourceMonitorTest { + + @InjectMocks + private ResourceMonitor resourceMonitor; + + @Mock + private OperatingSystemMXBean osMXBean; + + @Mock + private MemoryMXBean memoryMXBean; + + @Spy + private AtomicReference currentStatus = new AtomicReference<>(ResourceStatus.OK); + + @Spy + private AtomicReference latestMetrics = new AtomicReference<>(new ResourceMetrics()); + + @BeforeEach + void setUp() { + // Set thresholds for testing + ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9); + ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75); + ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9); + ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75); + ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean); + ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean); + ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus); + ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics); + } + + @Test + void shouldCalculateDynamicQueueCapacity() { + // Given + int baseCapacity = 10; + int minCapacity = 2; + + // Mock current status as OK + currentStatus.set(ResourceStatus.OK); + + // When + int capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity); + + // Then + assertEquals(baseCapacity, capacity, "With OK status, capacity should equal base capacity"); + + // Given + currentStatus.set(ResourceStatus.WARNING); + + // When + capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity); + + // Then + assertEquals(6, capacity, "With WARNING status, capacity should be reduced to 60%"); + + // Given + currentStatus.set(ResourceStatus.CRITICAL); + + // When + capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity); + + // Then + assertEquals(3, capacity, "With CRITICAL status, capacity should be reduced to 30%"); + + // Test minimum capacity enforcement + assertEquals(minCapacity, resourceMonitor.calculateDynamicQueueCapacity(1, minCapacity), + "Should never go below minimum capacity"); + } + + @ParameterizedTest + @CsvSource({ + "10, OK, false", // Light job, OK status + "10, WARNING, false", // Light job, WARNING status + "10, CRITICAL, true", // Light job, CRITICAL status + "30, OK, false", // Medium job, OK status + "30, WARNING, true", // Medium job, WARNING status + "30, CRITICAL, true", // Medium job, CRITICAL status + "80, OK, true", // Heavy job, OK status + "80, WARNING, true", // Heavy job, WARNING status + "80, CRITICAL, true" // Heavy job, CRITICAL status + }) + void shouldQueueJobBasedOnWeightAndStatus(int weight, ResourceStatus status, boolean shouldQueue) { + // Given + currentStatus.set(status); + + // When + boolean result = resourceMonitor.shouldQueueJob(weight); + + // Then + assertEquals(shouldQueue, result, + String.format("For weight %d and status %s, shouldQueue should be %s", + weight, status, shouldQueue)); + } + + @Test + void resourceMetricsShouldDetectStaleState() { + // Given + Instant now = Instant.now(); + Instant pastInstant = now.minusMillis(6000); + + ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant); + ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now); + + // When/Then + assertTrue(staleMetrics.isStale(5000), "Metrics from 6 seconds ago should be stale with 5s threshold"); + assertFalse(freshMetrics.isStale(5000), "Fresh metrics should not be stale"); + } +} \ No newline at end of file diff --git a/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/common/src/test/java/stirling/software/common/service/TaskManagerTest.java new file mode 100644 index 000000000..a65b63167 --- /dev/null +++ b/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -0,0 +1,255 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.JobStats; + +class TaskManagerTest { + + @Mock + private FileStorage fileStorage; + + @InjectMocks + private TaskManager taskManager; + + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @Test + void testCreateTask() { + // Act + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertEquals(jobId, result.getJobId()); + assertFalse(result.isComplete()); + assertNotNull(result.getCreatedAt()); + } + + @Test + void testSetResult() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + Object resultObject = "Test result"; + + // Act + taskManager.setResult(jobId, resultObject); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals(resultObject, result.getResult()); + assertNotNull(result.getCompletedAt()); + } + + @Test + void testSetFileResult() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + String fileId = "file-id"; + String originalFileName = "test.pdf"; + String contentType = "application/pdf"; + + // Act + taskManager.setFileResult(jobId, fileId, originalFileName, contentType); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals(fileId, result.getFileId()); + assertEquals(originalFileName, result.getOriginalFileName()); + assertEquals(contentType, result.getContentType()); + assertNotNull(result.getCompletedAt()); + } + + @Test + void testSetError() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + String errorMessage = "Test error"; + + // Act + taskManager.setError(jobId, errorMessage); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals(errorMessage, result.getError()); + assertNotNull(result.getCompletedAt()); + } + + @Test + void testSetComplete_WithExistingResult() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + Object resultObject = "Test result"; + taskManager.setResult(jobId, resultObject); + + // Act + taskManager.setComplete(jobId); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals(resultObject, result.getResult()); + } + + @Test + void testSetComplete_WithoutExistingResult() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + + // Act + taskManager.setComplete(jobId); + + // Assert + JobResult result = taskManager.getJobResult(jobId); + assertNotNull(result); + assertTrue(result.isComplete()); + assertEquals("Task completed successfully", result.getResult()); + } + + @Test + void testIsComplete() { + // Arrange + String jobId = UUID.randomUUID().toString(); + taskManager.createTask(jobId); + + // Assert - not complete initially + assertFalse(taskManager.isComplete(jobId)); + + // Act - mark as complete + taskManager.setComplete(jobId); + + // Assert - now complete + assertTrue(taskManager.isComplete(jobId)); + } + + @Test + void testGetJobStats() { + // Arrange + // 1. Create active job + String activeJobId = "active-job"; + taskManager.createTask(activeJobId); + + // 2. Create completed successful job with file + String successFileJobId = "success-file-job"; + taskManager.createTask(successFileJobId); + taskManager.setFileResult(successFileJobId, "file-id", "test.pdf", "application/pdf"); + + // 3. Create completed successful job without file + String successJobId = "success-job"; + taskManager.createTask(successJobId); + taskManager.setResult(successJobId, "Result"); + + // 4. Create failed job + String failedJobId = "failed-job"; + taskManager.createTask(failedJobId); + taskManager.setError(failedJobId, "Error message"); + + // Act + JobStats stats = taskManager.getJobStats(); + + // Assert + assertEquals(4, stats.getTotalJobs()); + assertEquals(1, stats.getActiveJobs()); + assertEquals(3, stats.getCompletedJobs()); + assertEquals(1, stats.getFailedJobs()); + assertEquals(2, stats.getSuccessfulJobs()); + assertEquals(1, stats.getFileResultJobs()); + assertNotNull(stats.getNewestActiveJobTime()); + assertNotNull(stats.getOldestActiveJobTime()); + assertTrue(stats.getAverageProcessingTimeMs() >= 0); + } + + @Test + void testCleanupOldJobs() throws Exception { + // Arrange + // 1. Create a recent completed job + String recentJobId = "recent-job"; + taskManager.createTask(recentJobId); + taskManager.setResult(recentJobId, "Result"); + + // 2. Create an old completed job with file result + String oldJobId = "old-job"; + taskManager.createTask(oldJobId); + JobResult oldJob = taskManager.getJobResult(oldJobId); + + // Manually set the completion time to be older than the expiry + LocalDateTime oldTime = LocalDateTime.now().minusHours(1); + ReflectionTestUtils.setField(oldJob, "completedAt", oldTime); + ReflectionTestUtils.setField(oldJob, "complete", true); + ReflectionTestUtils.setField(oldJob, "fileId", "file-id"); + ReflectionTestUtils.setField(oldJob, "originalFileName", "test.pdf"); + ReflectionTestUtils.setField(oldJob, "contentType", "application/pdf"); + + when(fileStorage.deleteFile("file-id")).thenReturn(true); + + // Obtain access to the private jobResults map + Map jobResultsMap = (Map) ReflectionTestUtils.getField(taskManager, "jobResults"); + + // 3. Create an active job + String activeJobId = "active-job"; + taskManager.createTask(activeJobId); + + // Verify all jobs are in the map + assertTrue(jobResultsMap.containsKey(recentJobId)); + assertTrue(jobResultsMap.containsKey(oldJobId)); + assertTrue(jobResultsMap.containsKey(activeJobId)); + + // Act + taskManager.cleanupOldJobs(); + + // Assert - the old job should be removed + assertFalse(jobResultsMap.containsKey(oldJobId)); + assertTrue(jobResultsMap.containsKey(recentJobId)); + assertTrue(jobResultsMap.containsKey(activeJobId)); + verify(fileStorage).deleteFile("file-id"); + } + + @Test + void testShutdown() throws Exception { + // This mainly tests that the shutdown method doesn't throw exceptions + taskManager.shutdown(); + + // Verify the executor service is shutdown + // This is difficult to test directly, but we can verify it doesn't throw exceptions + } +} diff --git a/stirling-pdf/src/main/java/stirling/software/common/controller/JobController.java b/stirling-pdf/src/main/java/stirling/software/common/controller/JobController.java index a08d97575..6f70f23e1 100644 --- a/stirling-pdf/src/main/java/stirling/software/common/controller/JobController.java +++ b/stirling-pdf/src/main/java/stirling/software/common/controller/JobController.java @@ -3,6 +3,7 @@ package stirling.software.common.controller; import java.util.Map; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -14,6 +15,7 @@ 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.JobQueue; import stirling.software.common.service.TaskManager; /** REST controller for job-related endpoints */ @@ -24,6 +26,7 @@ public class JobController { private final TaskManager taskManager; private final FileStorage fileStorage; + private final JobQueue jobQueue; /** * Get the status of a job @@ -37,6 +40,19 @@ public class JobController { if (result == null) { return ResponseEntity.notFound().build(); } + + // Check if the job is in the queue and add queue information + if (!result.isComplete() && jobQueue.isJobQueued(jobId)) { + int position = jobQueue.getJobPosition(jobId); + Map resultWithQueueInfo = + Map.of( + "jobResult", + result, + "queueInfo", + Map.of("inQueue", true, "position", position)); + return ResponseEntity.ok(resultWithQueueInfo); + } + return ResponseEntity.ok(result); } @@ -93,6 +109,17 @@ public class JobController { return ResponseEntity.ok(stats); } + /** + * Get statistics about the job queue + * + * @return Queue statistics + */ + @GetMapping("/api/v1/general/job/queue/stats") + public ResponseEntity getQueueStats() { + Map queueStats = jobQueue.getQueueStats(); + return ResponseEntity.ok(queueStats); + } + /** * Manually trigger cleanup of old jobs * @@ -111,4 +138,59 @@ public class JobController { "removedJobs", removedCount, "remainingJobs", afterCount)); } + + /** + * Cancel a job by its ID + * + * @param jobId The job ID + * @return Response indicating whether the job was cancelled + */ + @DeleteMapping("/api/v1/general/job/{jobId}") + public ResponseEntity cancelJob(@PathVariable("jobId") String jobId) { + log.debug("Request to cancel job: {}", jobId); + + // First check if the job is in the queue + boolean cancelled = false; + int queuePosition = -1; + + if (jobQueue.isJobQueued(jobId)) { + queuePosition = jobQueue.getJobPosition(jobId); + cancelled = jobQueue.cancelJob(jobId); + log.info("Cancelled queued job: {} (was at position {})", jobId, queuePosition); + } + + // If not in queue or couldn't cancel, try to cancel in TaskManager + if (!cancelled) { + JobResult result = taskManager.getJobResult(jobId); + if (result != null && !result.isComplete()) { + // Mark as error with cancellation message + taskManager.setError(jobId, "Job was cancelled by user"); + cancelled = true; + log.info("Marked job as cancelled in TaskManager: {}", jobId); + } + } + + if (cancelled) { + return ResponseEntity.ok( + Map.of( + "message", + "Job cancelled successfully", + "wasQueued", + queuePosition >= 0, + "queuePosition", + queuePosition >= 0 ? queuePosition : "n/a")); + } else { + // Job not found or already complete + JobResult result = taskManager.getJobResult(jobId); + if (result == null) { + return ResponseEntity.notFound().build(); + } else if (result.isComplete()) { + return ResponseEntity.badRequest() + .body(Map.of("message", "Cannot cancel job that is already complete")); + } else { + return ResponseEntity.internalServerError() + .body(Map.of("message", "Failed to cancel job for unknown reason")); + } + } + } } diff --git a/stirling-pdf/src/test/java/stirling/software/common/controller/JobControllerTest.java b/stirling-pdf/src/test/java/stirling/software/common/controller/JobControllerTest.java new file mode 100644 index 000000000..8e36e84a8 --- /dev/null +++ b/stirling-pdf/src/test/java/stirling/software/common/controller/JobControllerTest.java @@ -0,0 +1,365 @@ +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.JobQueue; +import stirling.software.common.service.TaskManager; + +class JobControllerTest { + + @Mock + private TaskManager taskManager; + + @Mock + private FileStorage fileStorage; + + @Mock + private JobQueue jobQueue; + + @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_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 responseBody = (Map) response.getBody(); + assertEquals(mockResult, responseBody.get("jobResult")); + + @SuppressWarnings("unchecked") + Map queueInfo = (Map) 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 responseBody = (Map) 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 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"; + 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 responseBody = (Map) 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); + + 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 responseBody = (Map) 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"; + 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); + + 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 responseBody = (Map) response.getBody(); + assertEquals("Cannot cancel job that is already complete", responseBody.get("message")); + } +} \ No newline at end of file