diff --git a/AGENTS.md b/AGENTS.md index ab0516e..5a70537 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,11 @@ # Agent Instructions -## GitHub CLI (`gh`) Usage -When running `gh` commands in this project via an automated agent environment, ensure you bypass the default `GITHUB_TOKEN` environment variable. The agent environment may have an invalid `GITHUB_TOKEN` set, which `gh` prioritizes over valid keyring credentials, resulting in an `HTTP 401: Bad credentials` error. +## Project Context +When working on this project, always read the [`README.md`](README.md) file to obtain full context on project architecture, setup instructions, and features. + +## GitHub CLI (`gh`) & Git Usage +- Always run `git` commands (e.g., `git status`, `git diff`, `git add`, `git commit`, `git push`) and `gh` commands unsandboxed (setting `BypassSandbox: true` when calling `run_command`) to ensure git hooks, local tools, and remote repository authentication work without sandbox errors. +- When running `gh` commands in this project via an automated agent environment, ensure you bypass the default `GITHUB_TOKEN` environment variable. The agent environment may have an invalid `GITHUB_TOKEN` set, which `gh` prioritizes over valid keyring credentials, resulting in an `HTTP 401: Bad credentials` error. **Workaround:** Prefix `gh` commands with `env -u GITHUB_TOKEN` to force the CLI to use the valid keyring authentication. @@ -23,3 +27,11 @@ env -u GITHUB_TOKEN gh pr create --title "..." --body "..." - Upgraded to Uppy v5. - Do **not** call `uppy.run()`. This method was deprecated/removed. Simply instantiating Uppy with `new Uppy()` and using plugins compiles and registers events automatically. - CSS styles must be imported from `@uppy/core/css/style.min.css` and `@uppy/dashboard/css/style.min.css` rather than the old `dist` structure or the legacy unified `uppy` css bundle. + +### 3. String Comparisons & Avoiding Deprecated StringUtils +- Do not use deprecated `StringUtils` comparison methods such as `StringUtils.equals(...)` or `StringUtils.equalsIgnoreCase(...)`. +- Always use `org.apache.commons.lang3.Strings.CS` for case-sensitive operations (e.g., `Strings.CS.equals(...)`, `Strings.CS.startsWith(...)`) and `org.apache.commons.lang3.Strings.CI` for case-insensitive operations (e.g., `Strings.CI.equals(...)`, `Strings.CI.startsWith(...)`). + +### 4. Maven Build Execution & Unsandboxed Mode +- Always run Maven build commands unsandboxed (e.g., setting `BypassSandbox: true` when calling `run_command`) to allow access to local Maven repository (`~/.m2`) and dependency resolution. + diff --git a/spring-boot-rest/pom.xml b/spring-boot-rest/pom.xml index f7019aa..900eabd 100644 --- a/spring-boot-rest/pom.xml +++ b/spring-boot-rest/pom.xml @@ -36,7 +36,12 @@ me.desair.tus tus-java-server - 1.0.0-3.2 + 2.0.0-SNAPSHOT + + + org.apache.commons + commons-lang3 + 3.18.0 io.tus.java.client diff --git a/spring-boot-rest/src/main/java/me/desair/spring/tus/App.java b/spring-boot-rest/src/main/java/me/desair/spring/tus/App.java index 644f238..dc94c74 100644 --- a/spring-boot-rest/src/main/java/me/desair/spring/tus/App.java +++ b/spring-boot-rest/src/main/java/me/desair/spring/tus/App.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Bean; import org.springframework.context.event.ContextRefreshedEvent; @@ -22,7 +23,7 @@ public class App implements ApplicationListener { @Value("${tus.server.data.directory}") protected String tusDataPath; - @Value("#{servletContext.contextPath}") + @Value("${server.servlet.context-path:/test}") private String servletContextPath; @Override @@ -44,4 +45,11 @@ public TusFileUploadService tusFileUploadService() { .withUploadUri(servletContextPath + "/api/upload") .withThreadLocalCache(true); } + + @Bean + public TomcatServletWebServerFactory tomcatFactory(TusFileUploadService tusFileUploadService) { + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(); + factory.addContextValves(new TusInterimResponseTomcatValve(tusFileUploadService)); + return factory; + } } diff --git a/spring-boot-rest/src/main/java/me/desair/spring/tus/FileUploadController.java b/spring-boot-rest/src/main/java/me/desair/spring/tus/FileUploadController.java index bbd8acb..c60e962 100644 --- a/spring-boot-rest/src/main/java/me/desair/spring/tus/FileUploadController.java +++ b/spring-boot-rest/src/main/java/me/desair/spring/tus/FileUploadController.java @@ -1,10 +1,8 @@ package me.desair.spring.tus; -import java.io.IOException; - import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; - +import java.io.IOException; import me.desair.tus.server.TusFileUploadService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; @@ -14,19 +12,31 @@ @Controller @RequestMapping(value = "/api/upload") -//access Cros +// access Cros @CrossOrigin(origins = "*") public class FileUploadController { - @Autowired - private TusFileUploadService tusFileUploadService; + @Autowired private TusFileUploadService tusFileUploadService; - @RequestMapping(value = {"", "/**"}, method = {RequestMethod.POST, RequestMethod.PATCH, RequestMethod.HEAD, - RequestMethod.DELETE, RequestMethod.OPTIONS, RequestMethod.GET}) - public void processUpload(final HttpServletRequest servletRequest, final HttpServletResponse servletResponse) throws IOException { - tusFileUploadService.process(servletRequest, servletResponse); - //access response header Location,Upload-Offset,Upload-length - servletResponse.addHeader("Access-Control-Expose-Headers","Location,Upload-Offset,Upload-Length"); + @RequestMapping( + value = {"", "/**"}, + method = { + RequestMethod.POST, + RequestMethod.PATCH, + RequestMethod.HEAD, + RequestMethod.DELETE, + RequestMethod.OPTIONS, + RequestMethod.GET + }) + public void processUpload( + final HttpServletRequest servletRequest, final HttpServletResponse servletResponse) + throws IOException { + int statusBefore = servletResponse.getStatus(); + tusFileUploadService.process(servletRequest, servletResponse); + servletResponse.addHeader( + "Access-Control-Expose-Headers", "Location,Upload-Offset,Upload-Length"); + if (servletResponse.getStatus() != statusBefore) { + servletResponse.setStatus(servletResponse.getStatus()); } - + } } diff --git a/spring-boot-rest/src/main/java/me/desair/spring/tus/TusInterimResponseTomcatValve.java b/spring-boot-rest/src/main/java/me/desair/spring/tus/TusInterimResponseTomcatValve.java new file mode 100644 index 0000000..12a0894 --- /dev/null +++ b/spring-boot-rest/src/main/java/me/desair/spring/tus/TusInterimResponseTomcatValve.java @@ -0,0 +1,225 @@ +package me.desair.spring.tus; + +import jakarta.servlet.ServletException; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import me.desair.tus.server.TusFileUploadService; +import org.apache.catalina.connector.Request; +import org.apache.catalina.connector.Response; +import org.apache.catalina.valves.ValveBase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Tomcat Valve implementation that inspects incoming HTTP requests using {@link + * TusFileUploadService#getRawInterimResponse(jakarta.servlet.http.HttpServletRequest, String)} and writes + * raw HTTP 104 interim response frames directly to Tomcat's underlying TCP socket before servlet + * execution. + * + *

Architectural Reasoning & Container Limitations

+ * + *

Standard Servlet specification (Jakarta Servlet 6.0) and Tomcat Catalina container APIs + * enforce a strict HTTP response lifecycle (Status Line → Headers → Response Body) via + * {@link jakarta.servlet.http.HttpServletResponse} and {@link jakarta.servlet.ServletOutputStream}. + * Attempting to write bytes via standard output streams will treat those bytes as part of the + * response body after committing status code 200, rather than emitting preliminary 1xx + * informational response lines (such as {@code HTTP/1.1 104 Resumption}). + * + *

While Tomcat provides internal APIs like {@code Response#sendAcknowledgement()} (via Coyote + * {@code ActionCode.ACK}), that mechanism is hardcoded exclusively for HTTP 100 Continue frames. + * Tomcat 10 / Servlet 6.0 does not expose a public, non-reflective API for writing arbitrary raw + * 1xx interim status frames directly to the TCP socket before servlet dispatch. + * + *

Reflection & Performance Optimization

+ * + *

To emit raw HTTP 104 frames, this valve retrieves Tomcat's low-level {@code SocketWrapperBase} + * from Coyote's internal {@code Http11OutputBuffer}. To eliminate per-request reflection overhead, + * target {@link Field} and {@link Method} instances are resolved and cached statically upon first + * execution. + * + *

Production Best Practices & Upgrade Path

+ * + * + */ +public class TusInterimResponseTomcatValve extends ValveBase { + + private static final Logger LOG = LoggerFactory.getLogger(TusInterimResponseTomcatValve.class); + + // Cached reflection fields and methods to avoid per-request lookup overhead + private static Field outputBufferField; + private static Field socketWrapperField; + private static Method writeMethod; + private static Method flushMethod; + + private final TusFileUploadService tusFileUploadService; + + /** + * Constructs a new Tomcat Valve for emitting HTTP 104 interim responses. + * + * @param tusFileUploadService the upload service used to generate raw interim response frames + */ + public TusInterimResponseTomcatValve(TusFileUploadService tusFileUploadService) { + this.tusFileUploadService = tusFileUploadService; + } + + @Override + public void invoke(Request request, Response response) throws IOException, ServletException { + if (tusFileUploadService != null) { + try { + // Step 1: Inspect the incoming request to check if a 104 interim response frame is needed + String rawInterimResponse = + tusFileUploadService.getRawInterimResponse(request.getRequest(), null); + + if (rawInterimResponse != null) { + byte[] bytes = rawInterimResponse.getBytes(StandardCharsets.UTF_8); + + // Step 2: Write raw HTTP 104 bytes directly to Tomcat's underlying SocketWrapperBase + boolean written = writeToSocketWrapper(response, bytes); + if (written) { + LOG.info( + "Emitted raw HTTP 104 Interim Response via Tomcat SocketWrapper for request URI: {}", + request.getRequestURI()); + } else { + LOG.warn("Could not obtain Tomcat SocketWrapper to emit 104 interim response"); + } + } + } catch (Exception e) { + LOG.warn("Error emitting HTTP 104 interim response in Tomcat Valve", e); + } + } + + // Step 3: Continue normal request processing down Tomcat's pipeline to the target servlet + getNext().invoke(request, response); + } + + /** + * Writes raw bytes directly to Tomcat's underlying {@code SocketWrapperBase}. + * + *

ponytail: Tomcat encapsulates SocketWrapperBase inside Http11OutputBuffer without exposing a + * public raw 1xx writing API in Tomcat 10. Reflection lookups are cached to eliminate per-request + * reflection overhead. Upgrade path: Replace with Tomcat 12+ sendEarlyHints / Servlet 6.2 1xx + * APIs once standard. + * + * @param response Tomcat's Catalina Response connector object + * @param bytes the UTF-8 encoded HTTP 104 frame bytes to write + * @return true if successfully written and flushed to the socket; false otherwise + */ + private boolean writeToSocketWrapper(Response response, byte[] bytes) { + try { + // Access low-level Coyote response object + org.apache.coyote.Response coyoteResponse = response.getCoyoteResponse(); + + // Extract low-level output buffer object (e.g. Http11OutputBuffer) + Object outputBuffer = getOutputBuffer(coyoteResponse); + if (outputBuffer != null) { + + // Extract SocketWrapperBase instance from output buffer + Object socketWrapper = getSocketWrapper(outputBuffer); + if (socketWrapper != null) { + + // Invoke socketWrapper.write(...) and socketWrapper.flush(...) + return invokeWriteAndFlush(socketWrapper, bytes); + } + } + } catch (Exception e) { + LOG.warn("Failed to write to Tomcat SocketWrapperBase via reflection", e); + } + return false; + } + + /** + * Resolves and caches the private {@code outputBuffer} field on {@link + * org.apache.coyote.Response}. + * + * @param coyoteResponse the low-level Coyote response instance + * @return the internal output buffer object, or null if unresolvable + * @throws Exception if reflection access fails + */ + private Object getOutputBuffer(org.apache.coyote.Response coyoteResponse) throws Exception { + if (outputBufferField == null) { + Field field = org.apache.coyote.Response.class.getDeclaredField("outputBuffer"); + field.setAccessible(true); + outputBufferField = field; + } + return outputBufferField.get(coyoteResponse); + } + + /** + * Traverses the class hierarchy of the output buffer to resolve and cache the {@code + * socketWrapper} field. + * + * @param outputBuffer the output buffer instance (e.g., Http11OutputBuffer) + * @return the SocketWrapperBase instance, or null if unresolvable + * @throws Exception if reflection access fails + */ + private Object getSocketWrapper(Object outputBuffer) throws Exception { + if (socketWrapperField == null + || !socketWrapperField.getDeclaringClass().isAssignableFrom(outputBuffer.getClass())) { + Field field = findDeclaredField(outputBuffer.getClass(), "socketWrapper"); + if (field != null) { + field.setAccessible(true); + socketWrapperField = field; + } + } + return socketWrapperField != null ? socketWrapperField.get(outputBuffer) : null; + } + + /** + * Resolves and invokes {@code write(boolean block, byte[] b, int off, int len)} and {@code + * flush(boolean block)} on Tomcat's low-level {@code SocketWrapperBase}. + * + * @param socketWrapper the SocketWrapperBase instance + * @param bytes the raw HTTP bytes to transmit + * @return true if write and flush succeeded + * @throws Exception if reflection invocation fails + */ + private boolean invokeWriteAndFlush(Object socketWrapper, byte[] bytes) throws Exception { + if (writeMethod == null) { + // Resolve SocketWrapperBase.write(boolean block, byte[] b, int off, int len) + writeMethod = + socketWrapper + .getClass() + .getMethod("write", boolean.class, byte[].class, int.class, int.class); + } + if (flushMethod == null) { + // Resolve SocketWrapperBase.flush(boolean block) + flushMethod = socketWrapper.getClass().getMethod("flush", boolean.class); + } + + // Write bytes synchronously (block = true) directly into socket buffer + writeMethod.invoke(socketWrapper, true, bytes, 0, bytes.length); + + // Flush socket buffer immediately to send raw 104 frame across the wire + flushMethod.invoke(socketWrapper, true); + return true; + } + + /** + * Helper utility to search for a declared field up through a class inheritance hierarchy. + * + * @param clazz the target class + * @param fieldName the name of the field to find + * @return the Field instance if found, or null + */ + private static Field findDeclaredField(Class clazz, String fieldName) { + Class current = clazz; + while (current != null) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + return null; + } +} diff --git a/spring-boot-rest/src/main/java/me/desair/spring/tus/WebMvcConfig.java b/spring-boot-rest/src/main/java/me/desair/spring/tus/WebMvcConfig.java index 8ba79bb..697a3b8 100644 --- a/spring-boot-rest/src/main/java/me/desair/spring/tus/WebMvcConfig.java +++ b/spring-boot-rest/src/main/java/me/desair/spring/tus/WebMvcConfig.java @@ -1,26 +1,23 @@ package me.desair.spring.tus; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; -import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { - private static final String[] CLASSPATH_RESOURCE_LOCATIONS = { "classpath:/public/" }; + private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {"classpath:/public/"}; - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - if (!registry.hasMappingForPattern("/webjars/**")) { - registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/"); - } - if (!registry.hasMappingForPattern("/**")) { - registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS); - } + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + if (!registry.hasMappingForPattern("/webjars/**")) { + registry + .addResourceHandler("/webjars/**") + .addResourceLocations("classpath:/META-INF/resources/webjars/"); } - + if (!registry.hasMappingForPattern("/**")) { + registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS); + } + } } diff --git a/spring-boot-rest/src/main/java/me/desair/spring/tus/client/UploadScript.java b/spring-boot-rest/src/main/java/me/desair/spring/tus/client/UploadScript.java index 3c644c6..fd3cb2a 100644 --- a/spring-boot-rest/src/main/java/me/desair/spring/tus/client/UploadScript.java +++ b/spring-boot-rest/src/main/java/me/desair/spring/tus/client/UploadScript.java @@ -1,82 +1,83 @@ package me.desair.spring.tus.client; -import java.io.File; -import java.io.IOException; -import java.net.URL; - import io.tus.java.client.ProtocolException; import io.tus.java.client.TusClient; import io.tus.java.client.TusExecutor; import io.tus.java.client.TusURLMemoryStore; import io.tus.java.client.TusUpload; import io.tus.java.client.TusUploader; +import java.io.File; +import java.io.IOException; +import java.net.URL; public class UploadScript { - public static void main(String args[]) throws IOException, ProtocolException { - if(args == null || args.length != 1) { - System.err.println("Usage: me.desair.spring.tus.client.UploadScript "); - } - - // Create a new TusClient instance - TusClient client = new TusClient(); - - // Configure tus HTTP endpoint. This URL will be used for creating new uploads - // using the Creation extension - client.setUploadCreationURL(new URL("http://localhost:8080/api/upload")); - - // Enable resumable uploads by storing the upload URL in memory - client.enableResuming(new TusURLMemoryStore()); - - // Open a file using which we will then create a TusUpload. If you do not have - // a File object, you can manually construct a TusUpload using an InputStream. - // See the documentation for more information. - File file = new File(args[0]); - final TusUpload upload = new TusUpload(file); - - System.out.println("Starting upload..."); - - // We wrap our uploading code in the TusExecutor class which will automatically catch - // exceptions and issue retries with small delays between them and take fully - // advantage of tus' resumability to offer more reliability. - // This step is optional but highly recommended. - TusExecutor executor = new TusExecutor() { - @Override - protected void makeAttempt() throws ProtocolException, IOException { - // First try to resume an upload. If that's not possible we will create a new - // upload and get a TusUploader in return. This class is responsible for opening - // a connection to the remote server and doing the uploading. - TusUploader uploader = client.resumeOrCreateUpload(upload); - - // Alternatively, if your tus server does not support the Creation extension - // and you obtained an upload URL from another service, you can instruct - // tus-java-client to upload to a specific URL. Please note that this is usually - // _not_ necessary and only if the tus server does not support the Creation - // extension. The Vimeo API would be an example where this method is needed. - // TusUploader uploader = client.beginOrResumeUploadFromURL(upload, new URL("https://tus.server.net/files/my_file")); - - // Upload the file in chunks of 1KB sizes. - uploader.setChunkSize(1024); - - // Upload the file as long as data is available. Once the - // file has been fully uploaded the method will return -1 - do { - // Calculate the progress using the total size of the uploading file and - // the current offset. - long totalBytes = upload.getSize(); - long bytesUploaded = uploader.getOffset(); - double progress = (double) bytesUploaded / totalBytes * 100; - - System.out.printf("Upload at %06.2f%%.\n", progress); - } while (uploader.uploadChunk() > -1); - - // Allow the HTTP connection to be closed and cleaned up - uploader.finish(); + public static void main(String args[]) throws IOException, ProtocolException { + if (args == null || args.length != 1) { + System.err.println("Usage: me.desair.spring.tus.client.UploadScript "); + } - System.out.println("Upload finished."); - System.out.format("Upload available at: %s", uploader.getUploadURL().toString()); - } + // Create a new TusClient instance + TusClient client = new TusClient(); + + // Configure tus HTTP endpoint. This URL will be used for creating new uploads + // using the Creation extension + client.setUploadCreationURL(new URL("http://localhost:8080/api/upload")); + + // Enable resumable uploads by storing the upload URL in memory + client.enableResuming(new TusURLMemoryStore()); + + // Open a file using which we will then create a TusUpload. If you do not have + // a File object, you can manually construct a TusUpload using an InputStream. + // See the documentation for more information. + File file = new File(args[0]); + final TusUpload upload = new TusUpload(file); + + System.out.println("Starting upload..."); + + // We wrap our uploading code in the TusExecutor class which will automatically catch + // exceptions and issue retries with small delays between them and take fully + // advantage of tus' resumability to offer more reliability. + // This step is optional but highly recommended. + TusExecutor executor = + new TusExecutor() { + @Override + protected void makeAttempt() throws ProtocolException, IOException { + // First try to resume an upload. If that's not possible we will create a new + // upload and get a TusUploader in return. This class is responsible for opening + // a connection to the remote server and doing the uploading. + TusUploader uploader = client.resumeOrCreateUpload(upload); + + // Alternatively, if your tus server does not support the Creation extension + // and you obtained an upload URL from another service, you can instruct + // tus-java-client to upload to a specific URL. Please note that this is usually + // _not_ necessary and only if the tus server does not support the Creation + // extension. The Vimeo API would be an example where this method is needed. + // TusUploader uploader = client.beginOrResumeUploadFromURL(upload, new + // URL("https://tus.server.net/files/my_file")); + + // Upload the file in chunks of 1KB sizes. + uploader.setChunkSize(1024); + + // Upload the file as long as data is available. Once the + // file has been fully uploaded the method will return -1 + do { + // Calculate the progress using the total size of the uploading file and + // the current offset. + long totalBytes = upload.getSize(); + long bytesUploaded = uploader.getOffset(); + double progress = (double) bytesUploaded / totalBytes * 100; + + System.out.printf("Upload at %06.2f%%.\n", progress); + } while (uploader.uploadChunk() > -1); + + // Allow the HTTP connection to be closed and cleaned up + uploader.finish(); + + System.out.println("Upload finished."); + System.out.format("Upload available at: %s", uploader.getUploadURL().toString()); + } }; - executor.makeAttempts(); - } + executor.makeAttempts(); + } } diff --git a/spring-boot-rest/src/test/java/me/desair/spring/tus/FileUploadControllerTest.java b/spring-boot-rest/src/test/java/me/desair/spring/tus/FileUploadControllerTest.java index eee45ce..8b5b7a6 100644 --- a/spring-boot-rest/src/test/java/me/desair/spring/tus/FileUploadControllerTest.java +++ b/spring-boot-rest/src/test/java/me/desair/spring/tus/FileUploadControllerTest.java @@ -1,8 +1,8 @@ package me.desair.spring.tus; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import me.desair.tus.server.TusFileUploadService; @@ -16,30 +16,28 @@ @AutoConfigureMockMvc public class FileUploadControllerTest { - @Autowired - private TusFileUploadService tusFileUploadService; - - @Autowired - private MockMvc mockMvc; - - @Test - public void contextLoads() { - assertNotNull(tusFileUploadService, "TusFileUploadService should be instantiated"); - } - - @Test - public void testUploadEndpointOptions() throws Exception { - // TUS OPTIONS request should return 204 No Content - mockMvc.perform(options("/api/upload") - .header("Tus-Resumable", "1.0.0")) - .andExpect(status().isNoContent()); - } - - @Test - public void testUploadEndpointPostWithoutHeaders() throws Exception { - // A standard POST request without Tus headers should be rejected by the Tus service (status 412) - // because the required Tus-Resumable header is missing. - mockMvc.perform(post("/api/upload")) - .andExpect(status().isPreconditionFailed()); - } + @Autowired private TusFileUploadService tusFileUploadService; + + @Autowired private MockMvc mockMvc; + + @Test + public void contextLoads() { + assertNotNull(tusFileUploadService, "TusFileUploadService should be instantiated"); + } + + @Test + public void testUploadEndpointOptions() throws Exception { + // TUS OPTIONS request should return 204 No Content + mockMvc + .perform(options("/api/upload").header("Tus-Resumable", "1.0.0")) + .andExpect(status().isNoContent()); + } + + @Test + public void testUploadEndpointPostWithoutHeaders() throws Exception { + // A standard POST request without Tus headers should be rejected by the Tus service (status + // 412) + // because the required Tus-Resumable header is missing. + mockMvc.perform(post("/api/upload")).andExpect(status().isPreconditionFailed()); + } } diff --git a/spring-boot-rest/src/test/java/me/desair/spring/tus/TusInterimResponseTomcatValveTest.java b/spring-boot-rest/src/test/java/me/desair/spring/tus/TusInterimResponseTomcatValveTest.java new file mode 100644 index 0000000..d23b0ab --- /dev/null +++ b/spring-boot-rest/src/test/java/me/desair/spring/tus/TusInterimResponseTomcatValveTest.java @@ -0,0 +1,35 @@ +package me.desair.spring.tus; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.http.HttpServletRequest; +import me.desair.tus.server.TusFileUploadService; +import org.apache.catalina.Valve; +import org.apache.catalina.connector.Request; +import org.apache.catalina.connector.Response; +import org.junit.jupiter.api.Test; + +public class TusInterimResponseTomcatValveTest { + + @Test + public void testInvokePassesThroughWhenNoInterimResponse() throws Exception { + TusFileUploadService service = mock(TusFileUploadService.class); + when(service.getRawInterimResponse(any(HttpServletRequest.class), any())).thenReturn(null); + + TusInterimResponseTomcatValve valve = new TusInterimResponseTomcatValve(service); + Valve nextValve = mock(Valve.class); + valve.setNext(nextValve); + + Request request = mock(Request.class); + Response response = mock(Response.class); + HttpServletRequest servletRequest = mock(HttpServletRequest.class); + when(request.getRequest()).thenReturn(servletRequest); + + valve.invoke(request, response); + + verify(nextValve).invoke(request, response); + } +}