mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Migrate stream to resource for stability (#6160)
This commit is contained in:
@@ -10,6 +10,7 @@ import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
@@ -157,13 +158,48 @@ public class FileStorage {
|
||||
success = true;
|
||||
} finally {
|
||||
if (!success) {
|
||||
Files.deleteIfExists(filePath);
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to clean up partial file {} after store failure",
|
||||
filePath,
|
||||
cleanupEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.debug("Stored StreamingResponseBody with ID: {}", fileId);
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a {@link Resource} body to disk, returning the generated file ID. Used by the async
|
||||
* job pipeline to capture {@code ResponseEntity<Resource>} results produced by controllers.
|
||||
*/
|
||||
public String storeFromResource(Resource resource, String originalName) throws IOException {
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
boolean success = false;
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
Files.copy(in, filePath);
|
||||
success = true;
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
} catch (IOException cleanupEx) {
|
||||
log.warn(
|
||||
"Failed to clean up partial file {} after store failure",
|
||||
filePath,
|
||||
cleanupEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.debug("Stored Resource with ID: {}", fileId);
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file by its ID
|
||||
*
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -321,6 +322,13 @@ public class JobExecutorService {
|
||||
log.debug(
|
||||
"Stored ResponseEntity<StreamingResponseBody> result with fileId: {}",
|
||||
fileId);
|
||||
} else if (body instanceof Resource resource) {
|
||||
String filename = extractResponseFilename(response);
|
||||
String contentType = extractResponseContentType(response);
|
||||
|
||||
String fileId = fileStorage.storeFromResource(resource, filename);
|
||||
taskManager.setFileResult(jobId, fileId, filename, contentType);
|
||||
log.debug("Stored ResponseEntity<Resource> result with fileId: {}", fileId);
|
||||
} else {
|
||||
// Check if the response body contains a fileId
|
||||
if (body != null && body.toString().contains("fileId")) {
|
||||
|
||||
@@ -16,11 +16,11 @@ import java.util.zip.ZipOutputStream;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
|
||||
import com.vladsch.flexmark.util.data.MutableDataSet;
|
||||
@@ -49,7 +49,7 @@ public class PDFToFile {
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
}
|
||||
|
||||
public ResponseEntity<StreamingResponseBody> processPdfToMarkdown(MultipartFile inputFile)
|
||||
public ResponseEntity<Resource> processPdfToMarkdown(MultipartFile inputFile)
|
||||
throws IOException, InterruptedException {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
@@ -169,7 +169,7 @@ public class PDFToFile {
|
||||
return PATTERN.matcher(markdown).replaceAll("$1(images/$2)");
|
||||
}
|
||||
|
||||
public ResponseEntity<StreamingResponseBody> processPdfToHtml(MultipartFile inputFile)
|
||||
public ResponseEntity<Resource> processPdfToHtml(MultipartFile inputFile)
|
||||
throws IOException, InterruptedException {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
@@ -232,7 +232,7 @@ public class PDFToFile {
|
||||
finalOut, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
public ResponseEntity<StreamingResponseBody> processPdfToOfficeFormat(
|
||||
public ResponseEntity<Resource> processPdfToOfficeFormat(
|
||||
MultipartFile inputFile, String outputFormat, String libreOfficeFilter)
|
||||
throws IOException, InterruptedException {
|
||||
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
|
||||
@@ -49,12 +52,7 @@ public class WebResponseUtils {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(mediaType);
|
||||
headers.setContentLength(bytes.length);
|
||||
String encodedDocName =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPlusSignPattern()
|
||||
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
|
||||
.replaceAll("%20");
|
||||
headers.setContentDispositionFormData("attachment", encodedDocName);
|
||||
headers.setContentDispositionFormData("attachment", encodeAttachmentName(docName));
|
||||
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
|
||||
}
|
||||
|
||||
@@ -73,7 +71,17 @@ public class WebResponseUtils {
|
||||
return baosToWebResponse(baos, docName);
|
||||
}
|
||||
|
||||
public static ResponseEntity<StreamingResponseBody> pdfDocToWebResponse(
|
||||
/**
|
||||
* Save a {@link PDDocument} to a managed temp file and return it as a file-backed {@code
|
||||
* ResponseEntity<Resource>}.
|
||||
*
|
||||
* <p>The returned {@link Resource} owns the supplied {@link TempFile} — the file is deleted
|
||||
* when Spring closes the response {@link InputStream} after writing the body. This is a
|
||||
* synchronous equivalent of the previous {@code StreamingResponseBody} pattern and avoids the
|
||||
* async-dispatch hazards (response-committed races, filter incompatibility, silent write
|
||||
* failures) that {@code StreamingResponseBody} introduced.
|
||||
*/
|
||||
public static ResponseEntity<Resource> pdfDocToWebResponse(
|
||||
PDDocument document, String docName, TempFileManager tempFileManager)
|
||||
throws IOException {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile(".pdf");
|
||||
@@ -87,38 +95,49 @@ public class WebResponseUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File to a web response (PDF default).
|
||||
* Convert a {@link TempFile} holding a PDF into a web response.
|
||||
*
|
||||
* <p>The temp file is deleted when Spring closes the response body stream.
|
||||
*
|
||||
* @param outputTempFile The temporary file to be sent as a response.
|
||||
* @param docName The name of the document.
|
||||
* @return A ResponseEntity containing the file as a resource.
|
||||
* @return A ResponseEntity whose body streams the file, deleting it on close.
|
||||
*/
|
||||
public static ResponseEntity<StreamingResponseBody> pdfFileToWebResponse(
|
||||
public static ResponseEntity<Resource> pdfFileToWebResponse(
|
||||
TempFile outputTempFile, String docName) throws IOException {
|
||||
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_PDF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File to a web response (ZIP default).
|
||||
* Convert a {@link TempFile} holding a ZIP archive into a web response.
|
||||
*
|
||||
* <p>The temp file is deleted when Spring closes the response body stream.
|
||||
*
|
||||
* @param outputTempFile The temporary file to be sent as a response.
|
||||
* @param docName The name of the document.
|
||||
* @return A ResponseEntity containing the file as a resource.
|
||||
* @return A ResponseEntity whose body streams the file, deleting it on close.
|
||||
*/
|
||||
public static ResponseEntity<StreamingResponseBody> zipFileToWebResponse(
|
||||
public static ResponseEntity<Resource> zipFileToWebResponse(
|
||||
TempFile outputTempFile, String docName) throws IOException {
|
||||
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File to a web response with explicit media type (e.g., ZIP).
|
||||
* Convert a {@link TempFile} into a web response with an explicit media type.
|
||||
*
|
||||
* <p>The returned {@link ResponseEntity} carries a {@link ManagedTempFileResource} as its body.
|
||||
* Spring's {@code ResourceHttpMessageConverter} calls {@link Resource#getInputStream()} once
|
||||
* and closes the returned stream after writing — at which point the underlying {@link TempFile}
|
||||
* is deleted. Everything runs synchronously on the request thread, so write failures propagate
|
||||
* through normal Spring error handling and are logged, rather than silently truncating the
|
||||
* response.
|
||||
*
|
||||
* @param outputTempFile The temporary file to be sent as a response.
|
||||
* @param docName The name of the document.
|
||||
* @param mediaType The content type to set on the response.
|
||||
* @return A ResponseEntity containing the file as a resource.
|
||||
* @return A ResponseEntity whose body streams the file, deleting it on close.
|
||||
*/
|
||||
public static ResponseEntity<StreamingResponseBody> fileToWebResponse(
|
||||
public static ResponseEntity<Resource> fileToWebResponse(
|
||||
TempFile outputTempFile, String docName, MediaType mediaType) throws IOException {
|
||||
|
||||
try {
|
||||
@@ -127,23 +146,9 @@ public class WebResponseUtils {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(mediaType);
|
||||
headers.setContentLength(len);
|
||||
String encodedDocName =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPlusSignPattern()
|
||||
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
|
||||
.replaceAll("%20");
|
||||
headers.setContentDispositionFormData("attachment", encodedDocName);
|
||||
|
||||
StreamingResponseBody body =
|
||||
os -> {
|
||||
try (os) {
|
||||
Files.copy(path, os);
|
||||
os.flush();
|
||||
} finally {
|
||||
outputTempFile.close();
|
||||
}
|
||||
};
|
||||
headers.setContentDispositionFormData("attachment", encodeAttachmentName(docName));
|
||||
|
||||
Resource body = new ManagedTempFileResource(outputTempFile);
|
||||
return new ResponseEntity<>(body, headers, HttpStatus.OK);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
try {
|
||||
@@ -154,4 +159,115 @@ public class WebResponseUtils {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private static String encodeAttachmentName(String docName) {
|
||||
return RegexPatternUtils.getInstance()
|
||||
.getPlusSignPattern()
|
||||
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
|
||||
.replaceAll("%20");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link Resource} backed by a {@link TempFile}. The underlying temp file is deleted when the
|
||||
* response {@code InputStream} is closed — i.e. after Spring has finished writing the body. Any
|
||||
* {@link IOException} during the copy is logged via {@link ClosingInputStream} and propagates
|
||||
* through Spring's normal error path. Since response headers are committed before the body
|
||||
* transfer begins, a mid-body failure manifests as a server-side log entry plus an aborted
|
||||
* connection rather than a silently-truncated success — which is the behaviour this class was
|
||||
* added to restore.
|
||||
*
|
||||
* <p><b>Single-use contract:</b> {@link #getInputStream()} is intended to be called once by
|
||||
* Spring's {@code ResourceHttpMessageConverter} on the normal write path. After the returned
|
||||
* stream is closed the backing temp file is deleted, so subsequent {@code getInputStream()}
|
||||
* calls will either see a deleted file (tests that mock {@link TempFile#close()} are an
|
||||
* exception) or fail at read time. Callers that need to re-read the body must copy it first.
|
||||
*/
|
||||
public static final class ManagedTempFileResource extends FileSystemResource {
|
||||
|
||||
private final TempFile tempFile;
|
||||
|
||||
public ManagedTempFileResource(TempFile tempFile) {
|
||||
super(tempFile.getFile());
|
||||
this.tempFile = tempFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
InputStream source;
|
||||
try {
|
||||
source = super.getInputStream();
|
||||
} catch (IOException e) {
|
||||
// Opening the input stream already failed; make sure we don't leak the temp file.
|
||||
try {
|
||||
tempFile.close();
|
||||
} catch (Exception closeEx) {
|
||||
e.addSuppressed(closeEx);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return new ClosingInputStream(source, tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream wrapper that deletes its backing {@link TempFile} on close. Logs — but does not
|
||||
* swallow — any IOException that happens while reading the body, so upstream handlers can
|
||||
* surface the failure to the client.
|
||||
*/
|
||||
private static final class ClosingInputStream extends FilterInputStream {
|
||||
|
||||
private final TempFile tempFile;
|
||||
private boolean closed;
|
||||
|
||||
ClosingInputStream(InputStream delegate, TempFile tempFile) {
|
||||
super(delegate);
|
||||
this.tempFile = tempFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
try {
|
||||
return super.read();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Failed to read temp response body {} while streaming to client",
|
||||
tempFile.getAbsolutePath(),
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
try {
|
||||
return super.read(b, off, len);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Failed to read temp response body {} while streaming to client",
|
||||
tempFile.getAbsolutePath(),
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
try {
|
||||
tempFile.close();
|
||||
} catch (Exception closeEx) {
|
||||
log.warn(
|
||||
"Failed to clean up temp file {} after streaming response",
|
||||
tempFile.getAbsolutePath(),
|
||||
closeEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -13,6 +16,8 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -185,4 +190,76 @@ class FileStorageTest {
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeFromResource_happyPath_persistsContent() throws IOException {
|
||||
// Arrange
|
||||
byte[] payload = "resource-body-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
Resource resource = new ByteArrayResource(payload);
|
||||
|
||||
// Act
|
||||
String fileId = fileStorage.storeFromResource(resource, "whatever.pdf");
|
||||
|
||||
// Assert
|
||||
assertNotNull(fileId);
|
||||
assertTrue(Files.exists(tempDir.resolve(fileId)));
|
||||
assertArrayEquals(payload, fileStorage.retrieveBytes(fileId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeFromResource_failureCleansUpPartialFile() throws IOException {
|
||||
// Arrange: a Resource whose getInputStream returns a stream that throws after
|
||||
// emitting a few bytes. The finally block in storeFromResource should remove
|
||||
// the partial file on disk.
|
||||
byte[] head = "partial".getBytes(StandardCharsets.UTF_8);
|
||||
Resource flakyResource =
|
||||
new ByteArrayResource(head) {
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new InputStream() {
|
||||
private int position = 0;
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
if (position < head.length) {
|
||||
return head[position++] & 0xFF;
|
||||
}
|
||||
throw new IOException("simulated mid-copy read failure");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException {
|
||||
if (position >= head.length) {
|
||||
throw new IOException("simulated mid-copy read failure");
|
||||
}
|
||||
int toCopy = Math.min(len, head.length - position);
|
||||
System.arraycopy(head, position, b, off, toCopy);
|
||||
position += toCopy;
|
||||
return toCopy;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Snapshot dir contents before the call so we can detect any lingering file.
|
||||
long filesBefore;
|
||||
try (Stream<Path> s = Files.list(tempDir)) {
|
||||
filesBefore = s.count();
|
||||
}
|
||||
|
||||
// Act + Assert: IOException must propagate out — not be swallowed.
|
||||
assertThrows(
|
||||
IOException.class, () -> fileStorage.storeFromResource(flakyResource, "n.pdf"));
|
||||
|
||||
// Assert: no partial file lingers under the storage directory — the finally
|
||||
// branch's deleteIfExists must have cleaned it up.
|
||||
long filesAfter;
|
||||
try (Stream<Path> s = Files.list(tempDir)) {
|
||||
filesAfter = s.count();
|
||||
}
|
||||
assertEquals(
|
||||
filesBefore,
|
||||
filesAfter,
|
||||
"partial file must be cleaned up by storeFromResource finally block");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
@@ -24,7 +26,12 @@ import org.mockito.Captor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -180,6 +187,46 @@ class JobExecutorServiceTest {
|
||||
verify(spy).runJobGeneric(eq(false), any(Supplier.class), eq(customTimeout));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistResponseEntityResourceBodyViaFileStorage() throws Exception {
|
||||
// Given: an async job whose result is a ResponseEntity<Resource> — the new
|
||||
// branch added by the stream-to-Resource migration. The executor must route
|
||||
// the body through FileStorage.storeFromResource and then record the result
|
||||
// via TaskManager.setFileResult with the filename/content-type extracted
|
||||
// from the response headers.
|
||||
byte[] payload = "resource-bytes".getBytes(StandardCharsets.UTF_8);
|
||||
Resource resource = new ByteArrayResource(payload);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
headers.setContentDisposition(
|
||||
ContentDisposition.formData().name("attachment").filename("result.pdf").build());
|
||||
|
||||
Supplier<Object> work = () -> new ResponseEntity<>(resource, headers, HttpStatus.OK);
|
||||
|
||||
when(fileStorage.storeFromResource(any(Resource.class), anyString()))
|
||||
.thenReturn("stored-file-id");
|
||||
|
||||
// When: run the job asynchronously — processJobResult runs on the executor.
|
||||
ResponseEntity<?> response = jobExecutorService.runJobGeneric(true, work);
|
||||
|
||||
// Then: the immediate return must be the JobResponse envelope.
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertInstanceOf(JobResponse.class, response.getBody());
|
||||
|
||||
// Wait for async processing and verify the Resource branch was taken —
|
||||
// FileStorage.storeFromResource was invoked with the same Resource instance,
|
||||
// and TaskManager.setFileResult recorded the extracted filename + content-type.
|
||||
verify(fileStorage, timeout(5000)).storeFromResource(eq(resource), eq("result.pdf"));
|
||||
verify(taskManager, timeout(5000))
|
||||
.setFileResult(
|
||||
anyString(),
|
||||
eq("stored-file-id"),
|
||||
eq("result.pdf"),
|
||||
eq(MediaType.APPLICATION_PDF_VALUE));
|
||||
verify(taskManager, timeout(5000)).setComplete(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleTimeout() throws Exception {
|
||||
// Given
|
||||
|
||||
@@ -25,12 +25,12 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
@@ -83,9 +83,11 @@ class PDFToFileTest {
|
||||
pdfToFile = new PDFToFile(mockTempFileManager, mockRuntimePathConfig);
|
||||
}
|
||||
|
||||
private static byte[] drain(ResponseEntity<StreamingResponseBody> response) throws IOException {
|
||||
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
response.getBody().writeTo(baos);
|
||||
try (java.io.InputStream __in = response.getBody().getInputStream()) {
|
||||
__in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
@@ -100,7 +102,7 @@ class PDFToFileTest {
|
||||
"This is not a PDF".getBytes());
|
||||
|
||||
// Execute
|
||||
ResponseEntity<StreamingResponseBody> response = pdfToFile.processPdfToMarkdown(nonPdfFile);
|
||||
ResponseEntity<Resource> response = pdfToFile.processPdfToMarkdown(nonPdfFile);
|
||||
|
||||
// Verify
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
@@ -117,7 +119,7 @@ class PDFToFileTest {
|
||||
"This is not a PDF".getBytes());
|
||||
|
||||
// Execute
|
||||
ResponseEntity<StreamingResponseBody> response = pdfToFile.processPdfToHtml(nonPdfFile);
|
||||
ResponseEntity<Resource> response = pdfToFile.processPdfToHtml(nonPdfFile);
|
||||
|
||||
// Verify
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
@@ -135,7 +137,7 @@ class PDFToFileTest {
|
||||
"This is not a PDF".getBytes());
|
||||
|
||||
// Execute
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
ResponseEntity<Resource> response =
|
||||
pdfToFile.processPdfToOfficeFormat(nonPdfFile, "docx", "draw_pdf_import");
|
||||
|
||||
// Verify
|
||||
@@ -154,7 +156,7 @@ class PDFToFileTest {
|
||||
"Fake PDF content".getBytes());
|
||||
|
||||
// Execute with invalid format
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
ResponseEntity<Resource> response =
|
||||
pdfToFile.processPdfToOfficeFormat(pdfFile, "invalid_format", "draw_pdf_import");
|
||||
|
||||
// Verify
|
||||
@@ -205,8 +207,7 @@ class PDFToFileTest {
|
||||
});
|
||||
|
||||
// Execute the method
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
pdfToFile.processPdfToMarkdown(pdfFile);
|
||||
ResponseEntity<Resource> response = pdfToFile.processPdfToMarkdown(pdfFile);
|
||||
|
||||
// Verify - should now return a ZIP file instead of plain markdown
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
@@ -298,8 +299,7 @@ class PDFToFileTest {
|
||||
});
|
||||
|
||||
// Execute the method
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
pdfToFile.processPdfToMarkdown(pdfFile);
|
||||
ResponseEntity<Resource> response = pdfToFile.processPdfToMarkdown(pdfFile);
|
||||
|
||||
// Verify
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
@@ -377,7 +377,7 @@ class PDFToFileTest {
|
||||
});
|
||||
|
||||
// Execute the method
|
||||
ResponseEntity<StreamingResponseBody> response = pdfToFile.processPdfToHtml(pdfFile);
|
||||
ResponseEntity<Resource> response = pdfToFile.processPdfToHtml(pdfFile);
|
||||
|
||||
// Verify
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
@@ -463,7 +463,7 @@ class PDFToFileTest {
|
||||
});
|
||||
|
||||
// Execute the method with docx format
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
ResponseEntity<Resource> response =
|
||||
pdfToFile.processPdfToOfficeFormat(pdfFile, "docx", "draw_pdf_import");
|
||||
|
||||
// Verify
|
||||
@@ -535,7 +535,7 @@ class PDFToFileTest {
|
||||
});
|
||||
|
||||
// Execute the method with ODP format
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
ResponseEntity<Resource> response =
|
||||
pdfToFile.processPdfToOfficeFormat(pdfFile, "odp", "draw_pdf_import");
|
||||
|
||||
// Verify
|
||||
@@ -620,7 +620,7 @@ class PDFToFileTest {
|
||||
});
|
||||
|
||||
// Execute the method with text format
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
ResponseEntity<Resource> response =
|
||||
pdfToFile.processPdfToOfficeFormat(pdfFile, "txt:Text", "draw_pdf_import");
|
||||
|
||||
// Verify
|
||||
@@ -679,7 +679,7 @@ class PDFToFileTest {
|
||||
});
|
||||
|
||||
// Execute the method
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
ResponseEntity<Resource> response =
|
||||
pdfToFile.processPdfToOfficeFormat(pdfFile, "docx", "draw_pdf_import");
|
||||
|
||||
// Verify
|
||||
@@ -726,7 +726,7 @@ class PDFToFileTest {
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
ResponseEntity<Resource> response =
|
||||
pdfToFileWithUno.processPdfToOfficeFormat(pdfFile, "docx", "writer_pdf_import");
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
@@ -790,7 +790,7 @@ class PDFToFileTest {
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
ResponseEntity<StreamingResponseBody> response =
|
||||
ResponseEntity<Resource> response =
|
||||
pdfToFileWithUno.processPdfToOfficeFormat(pdfFile, "docx", "writer_pdf_import");
|
||||
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
|
||||
@@ -3,17 +3,39 @@ package stirling.software.common.util;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
class WebResponseUtilsTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private TempFileManager tempFileManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUpTempFileManager() {
|
||||
TempFileRegistry registry = new TempFileRegistry();
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
|
||||
applicationProperties.getSystem().getTempFileManagement().setPrefix("wru-test-");
|
||||
tempFileManager = new TempFileManager(registry, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBytesToWebResponse_defaultMediaType() throws IOException {
|
||||
byte[] data = "test content".getBytes(StandardCharsets.UTF_8);
|
||||
@@ -91,4 +113,87 @@ class WebResponseUtilsTest {
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(0, response.getHeaders().getContentLength());
|
||||
}
|
||||
|
||||
@Test
|
||||
void managedTempFileResource_happyPathDeletesBackingFile() throws IOException {
|
||||
// Create a real managed temp file on disk and populate it.
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile(".pdf");
|
||||
byte[] payload = "fully-readable-pdf-body".getBytes(StandardCharsets.UTF_8);
|
||||
Files.write(tempFile.getPath(), payload);
|
||||
File backing = tempFile.getFile();
|
||||
assertTrue(backing.exists(), "precondition: backing file should exist");
|
||||
|
||||
WebResponseUtils.ManagedTempFileResource resource =
|
||||
new WebResponseUtils.ManagedTempFileResource(tempFile);
|
||||
|
||||
byte[] readBack;
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
readBack = in.readAllBytes();
|
||||
}
|
||||
|
||||
assertArrayEquals(payload, readBack, "stream should deliver the original bytes");
|
||||
assertFalse(
|
||||
backing.exists(),
|
||||
"backing temp file must be deleted once the response stream is closed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingInputStream_propagatesReadFailure() throws IOException {
|
||||
// ManagedTempFileResource is final, so we can't swap in a mock underlying stream.
|
||||
// Instead: open the resource's stream, close the inner FileInputStream early
|
||||
// (by closing the outer stream once), then confirm reading the now-closed stream
|
||||
// throws — exercising ClosingInputStream's read() catch/log/rethrow path. Finally
|
||||
// confirm the temp file is still cleaned up on close().
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile(".pdf");
|
||||
Files.write(tempFile.getPath(), "some-bytes".getBytes(StandardCharsets.UTF_8));
|
||||
File backing = tempFile.getFile();
|
||||
assertTrue(backing.exists());
|
||||
|
||||
WebResponseUtils.ManagedTempFileResource resource =
|
||||
new WebResponseUtils.ManagedTempFileResource(tempFile);
|
||||
|
||||
InputStream in = resource.getInputStream();
|
||||
// Pre-close the underlying stream to guarantee read() throws.
|
||||
in.close();
|
||||
// After close, the temp file has been deleted already.
|
||||
assertFalse(
|
||||
backing.exists(),
|
||||
"backing file is deleted on first close — precondition for read assertion");
|
||||
|
||||
// Any attempt to read from the already-closed stream must throw IOException
|
||||
// (not silently return -1). This exercises the ClosingInputStream.read() path
|
||||
// that logs and rethrows.
|
||||
IOException ioex = assertThrows(IOException.class, in::read);
|
||||
assertNotNull(ioex);
|
||||
|
||||
// close() is idempotent — a second call must not throw.
|
||||
assertDoesNotThrow(in::close);
|
||||
}
|
||||
|
||||
@Test
|
||||
void managedTempFileResource_openFailureCleansUp() throws IOException {
|
||||
// Arrange: build a TempFile whose backing file is deleted out from under it so
|
||||
// super.getInputStream() throws on open. Instrument close() with an AtomicBoolean
|
||||
// hook to confirm the cleanup path ran.
|
||||
AtomicBoolean closed = new AtomicBoolean(false);
|
||||
TempFile spying =
|
||||
new TempFile(tempFileManager, ".pdf") {
|
||||
@Override
|
||||
public void close() {
|
||||
closed.set(true);
|
||||
super.close();
|
||||
}
|
||||
};
|
||||
assertTrue(
|
||||
spying.getFile().delete(),
|
||||
"precondition: delete backing so super.getInputStream() fails");
|
||||
|
||||
WebResponseUtils.ManagedTempFileResource resource =
|
||||
new WebResponseUtils.ManagedTempFileResource(spying);
|
||||
|
||||
assertThrows(IOException.class, resource::getInputStream);
|
||||
assertTrue(
|
||||
closed.get(),
|
||||
"tempFile.close() must run when super.getInputStream() fails on open");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user