mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Pdf comment agent (#6196)
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
2dc5276e8b
commit
86774d556e
+134
@@ -0,0 +1,134 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
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 org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver;
|
||||
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
|
||||
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Controller tests for {@link PdfCommentAgentController}. The orchestrator is mocked so the test
|
||||
* never hits the engine or real filesystem.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdfCommentAgentControllerTest {
|
||||
|
||||
@Mock private PdfCommentAgentOrchestrator orchestrator;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
PdfCommentAgentController controller =
|
||||
new PdfCommentAgentController(orchestrator, JsonMapper.builder().build());
|
||||
mockMvc =
|
||||
MockMvcBuilders.standaloneSetup(controller)
|
||||
// standaloneSetup's defaults don't handle ResponseStatusException; wire up
|
||||
// both the ResponseStatusException resolver (for orchestrator 400s) and
|
||||
// DefaultHandlerExceptionResolver (so missing @RequestParam still 400s).
|
||||
.setHandlerExceptionResolvers(
|
||||
new ResponseStatusExceptionResolver(),
|
||||
new DefaultHandlerExceptionResolver())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsValidPdfAndReturnsAnnotatedBytes() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"input.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"%PDF-1.4\n%%EOF".getBytes());
|
||||
|
||||
byte[] annotatedBytes = "%PDF-1.4\n<annotated>\n%%EOF".getBytes();
|
||||
AnnotatedPdf stub = new AnnotatedPdf(annotatedBytes, "input-commented.pdf", 2, 2, "ok");
|
||||
when(orchestrator.applyComments(any(MultipartFile.class), eq("flag dates")))
|
||||
.thenReturn(stub);
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v1/ai/tools/pdf-comment-agent")
|
||||
.file(pdfFile)
|
||||
.param("prompt", "flag dates"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
.andExpect(
|
||||
header().string(
|
||||
"Content-Disposition",
|
||||
org.hamcrest.Matchers.containsString(
|
||||
"input-commented.pdf")))
|
||||
.andExpect(content().bytes(annotatedBytes));
|
||||
|
||||
verify(orchestrator).applyComments(any(MultipartFile.class), eq("flag dates"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesOrchestratorBadRequestForNonPdfUpload() throws Exception {
|
||||
// The controller delegates validation to the orchestrator; a ResponseStatusException
|
||||
// thrown by the orchestrator should propagate to Spring as a 400.
|
||||
MockMultipartFile notPdf =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
|
||||
when(orchestrator.applyComments(any(MultipartFile.class), eq("whatever")))
|
||||
.thenThrow(
|
||||
new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Only application/pdf uploads are supported"));
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v1/ai/tools/pdf-comment-agent")
|
||||
.file(notPdf)
|
||||
.param("prompt", "whatever"))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(orchestrator).applyComments(any(MultipartFile.class), eq("whatever"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingFileInput() throws Exception {
|
||||
mockMvc.perform(multipart("/api/v1/ai/tools/pdf-comment-agent").param("prompt", "test"))
|
||||
.andExpect(status().is4xxClientError());
|
||||
|
||||
verify(orchestrator, never()).applyComments(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingPromptParameter() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"input.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"%PDF-1.4\n%%EOF".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/ai/tools/pdf-comment-agent").file(pdfFile))
|
||||
.andExpect(status().is4xxClientError());
|
||||
|
||||
verify(orchestrator, never()).applyComments(any(), anyString());
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ConnectException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Verifies that AiEngineClient surfaces network-layer failures as structured HTTP statuses so every
|
||||
* AI tool caller sees a consistent, meaningful error rather than a raw 500.
|
||||
*/
|
||||
class AiEngineClientTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private HttpClient httpClient;
|
||||
private AiEngineClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getAiEngine().setEnabled(true);
|
||||
applicationProperties.getAiEngine().setUrl("http://localhost:5001");
|
||||
applicationProperties.getAiEngine().setTimeoutSeconds(5);
|
||||
httpClient = mock(HttpClient.class);
|
||||
client = new AiEngineClient(applicationProperties, httpClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postWrapsConnectIOExceptionAsServiceUnavailable() throws Exception {
|
||||
ConnectException cause = new ConnectException("Connection refused");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
assertSame(cause, ex.getCause(), "Original cause should be preserved for diagnostics");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postWrapsTimeoutAsGatewayTimeout() throws Exception {
|
||||
HttpTimeoutException cause = new HttpTimeoutException("request timed out");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
|
||||
assertEquals(HttpStatus.GATEWAY_TIMEOUT, ex.getStatusCode());
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWrapsGenericIOExceptionAsServiceUnavailable() throws Exception {
|
||||
IOException cause = new IOException("socket reset");
|
||||
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.get("/x"));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void postShortCircuitsWhenEngineDisabled() {
|
||||
applicationProperties.getAiEngine().setEnabled(false);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
class AiToolInputValidatorTest {
|
||||
|
||||
@Test
|
||||
void acceptsValidPdfUpload() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[] {1, 2});
|
||||
assertDoesNotThrow(() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullFile() {
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(null));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyFile() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[0]);
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPdfContentType() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "a.txt", "text/plain", new byte[] {1, 2});
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingContentType() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("fileInput", "a.pdf", null, new byte[] {1, 2});
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedFile() {
|
||||
// Mock getSize() to avoid allocating a 50 MB test payload.
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getContentType()).thenReturn("application/pdf");
|
||||
when(file.getSize()).thenReturn(AiToolInputValidator.MAX_INPUT_FILE_BYTES + 1);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> AiToolInputValidator.validatePdfUpload(file));
|
||||
assertEquals(HttpStatus.PAYLOAD_TOO_LARGE, ex.getStatusCode());
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
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 org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineResponse;
|
||||
import stirling.software.proprietary.model.api.ai.comments.PdfCommentInstruction;
|
||||
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
|
||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Smoke tests for {@link PdfCommentAgentOrchestrator}. Collaborators (engine client, PDF factory,
|
||||
* chunk extractor) are mocked; the orchestrator is exercised end-to-end on an in-memory PDF so the
|
||||
* returned bytes can be re-loaded and inspected.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdfCommentAgentOrchestratorTest {
|
||||
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@Mock private PdfTextChunkExtractor pdfTextChunkExtractor;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
private PdfAnnotationService pdfAnnotationService;
|
||||
private PdfCommentAgentOrchestrator orchestrator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = JsonMapper.builder().build();
|
||||
// Real (not mocked) — it's a pure primitive; exercising it in the test gives us stronger
|
||||
// assertions (the annotated PDF actually has the expected sticky notes).
|
||||
pdfAnnotationService = new PdfAnnotationService();
|
||||
orchestrator =
|
||||
new PdfCommentAgentOrchestrator(
|
||||
aiEngineClient,
|
||||
pdfTextChunkExtractor,
|
||||
pdfDocumentFactory,
|
||||
objectMapper,
|
||||
pdfAnnotationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPathAppliesValidInstructionsOnCorrectPagesAndReturnsBytes() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
byte[] pdfBytes = twoPagePdfBytes();
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
|
||||
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk zero");
|
||||
TextChunk c1 = new TextChunk("p0-c1", 0, 72f, 680f, 100f, 12f, "Chunk one");
|
||||
TextChunk c2 = new TextChunk("p1-c0", 1, 72f, 700f, 100f, 12f, "Chunk two");
|
||||
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0, c1, c2));
|
||||
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
new PdfCommentEngineResponse(
|
||||
"session-1",
|
||||
List.of(
|
||||
new PdfCommentInstruction(
|
||||
"p0-c0", "Comment on page 0", "alice", "Heads up"),
|
||||
new PdfCommentInstruction(
|
||||
"p1-c0", "Comment on page 1", null, null)),
|
||||
"reviewed");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "please comment");
|
||||
|
||||
assertEquals("doc-commented.pdf", result.fileName());
|
||||
assertNotNull(result.bytes(), "Returned bytes must not be null");
|
||||
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
|
||||
List<PDAnnotationText> page0Texts = textAnnotations(saved.getPage(0).getAnnotations());
|
||||
List<PDAnnotationText> page1Texts = textAnnotations(saved.getPage(1).getAnnotations());
|
||||
assertEquals(1, page0Texts.size(), "Exactly one annotation on page 0");
|
||||
assertEquals(1, page1Texts.size(), "Exactly one annotation on page 1");
|
||||
assertEquals("Comment on page 0", page0Texts.get(0).getContents());
|
||||
assertEquals("Comment on page 1", page1Texts.get(0).getContents());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownChunkIdsAreSkippedButValidOnesApplied() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
byte[] pdfBytes = twoPagePdfBytes();
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
|
||||
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk zero");
|
||||
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0));
|
||||
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
new PdfCommentEngineResponse(
|
||||
"session-2",
|
||||
List.of(
|
||||
new PdfCommentInstruction("p0-c0", "Valid", null, null),
|
||||
new PdfCommentInstruction("p999-c999", "Bogus", null, null)),
|
||||
"mixed");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "test");
|
||||
|
||||
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
|
||||
int totalAnnotations = 0;
|
||||
for (int i = 0; i < saved.getNumberOfPages(); i++) {
|
||||
totalAnnotations += textAnnotations(saved.getPage(i).getAnnotations()).size();
|
||||
}
|
||||
assertEquals(1, totalAnnotations, "Only the valid chunk annotation should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCommentsListReturnsDocumentWithoutAnnotations() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
byte[] pdfBytes = twoPagePdfBytes();
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
|
||||
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk");
|
||||
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0));
|
||||
|
||||
PdfCommentEngineResponse engineResponse =
|
||||
new PdfCommentEngineResponse("s", List.of(), "no comments worth making");
|
||||
when(aiEngineClient.post(anyString(), anyString()))
|
||||
.thenReturn(objectMapper.writeValueAsString(engineResponse));
|
||||
|
||||
AnnotatedPdf result = orchestrator.applyComments(input, "test");
|
||||
|
||||
assertEquals("doc-commented.pdf", result.fileName());
|
||||
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
|
||||
for (int i = 0; i < saved.getNumberOfPages(); i++) {
|
||||
assertTrue(
|
||||
textAnnotations(saved.getPage(i).getAnnotations()).isEmpty(),
|
||||
"Page " + i + " should have no text annotations");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyChunksListThrowsBadRequestAndDoesNotCallEngine() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
byte[] pdfBytes = twoPagePdfBytes();
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of());
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, "whatever"));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void promptTooLongThrowsBadRequestAndDoesNotCallEngine() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
String tooLong = "x".repeat(4001);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, tooLong));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankPromptThrowsBadRequestAndDoesNotCallEngine() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf");
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> orchestrator.applyComments(input, " "));
|
||||
assertEquals(400, ex.getStatusCode().value());
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static MockMultipartFile pdf(String filename) {
|
||||
return new MockMultipartFile(
|
||||
"fileInput",
|
||||
filename,
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"%PDF-1.4\n%%EOF".getBytes());
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText("Page " + i + " content");
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
|
||||
List<PDAnnotationText> out = new ArrayList<>();
|
||||
for (PDAnnotation a : annotations) {
|
||||
if (a instanceof PDAnnotationText t) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
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 java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PdfTextChunkExtractor}. Exercises chunk id format, bounding-box validity,
|
||||
* multi-page extraction, the empty-PDF path, and the 2000-chunk cap.
|
||||
*/
|
||||
class PdfTextChunkExtractorTest {
|
||||
|
||||
private static final Pattern CHUNK_ID_PATTERN = Pattern.compile("^p\\d+-c\\d+$");
|
||||
|
||||
private final PdfTextChunkExtractor extractor = new PdfTextChunkExtractor();
|
||||
|
||||
@Test
|
||||
void extractsOneChunkPerVisualLineWithValidBoundingBoxes() throws IOException {
|
||||
byte[] pdf = buildTwoPagePdf("Line A on page one", "Line B on page two");
|
||||
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TextChunk> chunks = extractor.extract(doc);
|
||||
|
||||
assertFalse(chunks.isEmpty(), "Extractor should produce at least one chunk");
|
||||
|
||||
for (TextChunk chunk : chunks) {
|
||||
assertTrue(
|
||||
CHUNK_ID_PATTERN.matcher(chunk.id()).matches(),
|
||||
"Chunk id should match p{page}-c{idx}, got: " + chunk.id());
|
||||
assertTrue(chunk.width() > 0f, "width > 0, chunk=" + chunk);
|
||||
assertTrue(chunk.height() > 0f, "height > 0, chunk=" + chunk);
|
||||
assertFalse(chunk.text() == null || chunk.text().isBlank(), "text non-blank");
|
||||
|
||||
PDRectangle box = doc.getPage(chunk.page()).getMediaBox();
|
||||
assertTrue(chunk.x() >= 0f, "x >= 0, chunk=" + chunk);
|
||||
assertTrue(chunk.y() >= 0f, "y >= 0, chunk=" + chunk);
|
||||
assertTrue(
|
||||
chunk.x() + chunk.width() <= box.getWidth() + 0.01f,
|
||||
"x + width fits within page width, chunk=" + chunk);
|
||||
assertTrue(
|
||||
chunk.y() + chunk.height() <= box.getHeight() + 0.01f,
|
||||
"y + height fits within page height, chunk=" + chunk);
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
chunks.stream().anyMatch(c -> c.page() == 0 && c.text().contains("Line A")),
|
||||
"Expected a page-0 chunk containing 'Line A'; chunks=" + chunks);
|
||||
assertTrue(
|
||||
chunks.stream().anyMatch(c -> c.page() == 1 && c.text().contains("Line B")),
|
||||
"Expected a page-1 chunk containing 'Line B'; chunks=" + chunks);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyListForPdfWithNoExtractableText() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
byte[] pdf = baos.toByteArray();
|
||||
|
||||
try (PDDocument loaded = Loader.loadPDF(pdf)) {
|
||||
List<TextChunk> chunks = extractor.extract(loaded);
|
||||
assertTrue(chunks.isEmpty(), "Expected no chunks, got=" + chunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void enforcesHardCapOf2000Chunks() throws IOException {
|
||||
byte[] pdf = buildPdfWithManyLines(2500);
|
||||
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
List<TextChunk> chunks = extractor.extract(doc);
|
||||
assertEquals(
|
||||
2000,
|
||||
chunks.size(),
|
||||
"Extractor should cap at MAX_CHUNKS_PER_DOC (2000); got=" + chunks.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private static byte[] buildTwoPagePdf(String page1Text, String page2Text) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
addPageWithLine(doc, page1Text);
|
||||
addPageWithLine(doc, page2Text);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPageWithLine(PDDocument doc, String text) throws IOException {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.beginText();
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.newLineAtOffset(72, 700);
|
||||
cs.showText(text);
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PDF with {@code totalLines} short lines of text spread across pages so the extractor
|
||||
* has to produce one chunk per line.
|
||||
*/
|
||||
private static byte[] buildPdfWithManyLines(int totalLines) throws IOException {
|
||||
int linesPerPage = 50;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
int remaining = totalLines;
|
||||
int lineCounter = 0;
|
||||
while (remaining > 0) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72, 780);
|
||||
int toWrite = Math.min(linesPerPage, remaining);
|
||||
for (int i = 0; i < toWrite; i++) {
|
||||
cs.showText("line-" + lineCounter++);
|
||||
if (i < toWrite - 1) {
|
||||
cs.newLineAtOffset(0, -14);
|
||||
}
|
||||
}
|
||||
cs.endText();
|
||||
}
|
||||
remaining -= linesPerPage;
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user