AsyncContextDelegateProviderImplTest.java
/*
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0, which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the
* Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
* version 2 with the GNU Classpath Exception, which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
*/
package org.glassfish.jersey.servlet.async;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.glassfish.jersey.servlet.spi.AsyncContextDelegate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class AsyncContextDelegateProviderImplTest {
@Mock
private HttpServletRequest request;
@Mock
private HttpServletResponse response;
@Mock
private AsyncContext asyncContext;
private AsyncContextDelegateProviderImpl provider;
@BeforeEach
void setUp() {
provider = new AsyncContextDelegateProviderImpl();
}
// -----------------------------------------------------------------------
// Factory method
// -----------------------------------------------------------------------
@Nested
@DisplayName("createDelegate()")
class CreateDelegate {
@Test
void returnsNonNull() {
AsyncContextDelegate delegate = provider.createDelegate(request, response);
assertNotNull(delegate);
assertFalse(delegate.isCompleted());
}
}
// -----------------------------------------------------------------------
// suspend()
// -----------------------------------------------------------------------
@Nested
@DisplayName("suspend()")
class Suspend {
@Test
@DisplayName("starts async on a non-async request")
void startsAsyncWhenNotAlreadyStarted() {
when(request.isAsyncStarted()).thenReturn(false);
when(request.startAsync(request, response)).thenReturn(asyncContext);
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.suspend();
verify(request).startAsync(request, response);
verify(asyncContext).setTimeout(-1L);
}
@Test
@DisplayName("reuses existing async context when async already started")
void reusesExistingAsyncContext() {
when(request.isAsyncStarted()).thenReturn(true);
when(request.getAsyncContext()).thenReturn(asyncContext);
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.suspend();
verify(request, never()).startAsync(any(), any());
verify(request).getAsyncContext();
verify(asyncContext).setTimeout(-1L);
}
@Test
@DisplayName("sets timeout to -1 (never time out) when starting async")
void setsNeverTimeoutOnNewAsyncContext() {
when(request.isAsyncStarted()).thenReturn(false);
when(request.startAsync(request, response)).thenReturn(asyncContext);
provider.createDelegate(request, response).suspend();
verify(asyncContext).setTimeout(-1L);
}
@Test
@DisplayName("does not start async twice on repeated suspend() calls")
void idempotentSuspend() {
when(request.isAsyncStarted()).thenReturn(false);
when(request.startAsync(request, response)).thenReturn(asyncContext);
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.suspend();
// second call must be a no-op
delegate.suspend();
verify(request, times(1)).startAsync(request, response);
}
@Test
@DisplayName("does not start async after complete() was already called")
void doesNotSuspendAfterComplete() {
AsyncContextDelegate delegate = provider.createDelegate(request, response);
// mark as completed first
delegate.complete();
// should be ignored
delegate.suspend();
verify(request, never()).isAsyncStarted();
verify(request, never()).startAsync(any(), any());
}
@Test
@DisplayName("handles IllegalStateException from asyncContext.setTimeout() gracefully")
void handlesSetTimeoutException() {
when(request.isAsyncStarted()).thenReturn(true);
when(request.getAsyncContext()).thenReturn(asyncContext);
doThrow(new IllegalStateException("already committed")).when(asyncContext).setTimeout(anyLong());
AsyncContextDelegate delegate = provider.createDelegate(request, response);
// Should not propagate the exception
assertDoesNotThrow(delegate::suspend);
}
}
// -----------------------------------------------------------------------
// complete()
// -----------------------------------------------------------------------
@Nested
@DisplayName("complete()")
class Complete {
@Test
@DisplayName("marks delegate as completed")
void marksAsCompleted() {
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.complete();
assertTrue(delegate.isCompleted());
}
@Test
@DisplayName("calls asyncContext.complete() when suspended")
void completesAsyncContextWhenSuspended() {
when(request.isAsyncStarted()).thenReturn(false);
when(request.startAsync(request, response)).thenReturn(asyncContext);
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.suspend();
delegate.complete();
verify(asyncContext).complete();
}
@Test
@DisplayName("does not call asyncContext.complete() when never suspended")
void doesNotCompleteAsyncContextWhenNeverSuspended() {
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.complete();
verify(asyncContext, never()).complete();
}
@Test
@DisplayName("second complete() call is a no-op (idempotent guard)")
void secondCompleteIsNoOp() {
when(request.isAsyncStarted()).thenReturn(false);
when(request.startAsync(request, response)).thenReturn(asyncContext);
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.suspend();
delegate.complete();
delegate.complete(); // must not throw and must not double-complete
verify(asyncContext, times(1)).complete();
}
}
// -----------------------------------------------------------------------
// isCompleted()
// -----------------------------------------------------------------------
@Nested
@DisplayName("isCompleted()")
class IsCompleted {
@Test
@DisplayName("returns false before complete() is called")
void falseBeforeComplete() {
assertFalse(provider.createDelegate(request, response).isCompleted());
}
@Test
@DisplayName("returns true after complete() is called")
void trueAfterComplete() {
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.complete();
assertTrue(delegate.isCompleted());
}
@Test
@DisplayName("returns true after complete(), even without a prior suspend()")
void trueAfterCompleteWithoutSuspend() {
AsyncContextDelegate delegate = provider.createDelegate(request, response);
delegate.complete();
assertTrue(delegate.isCompleted());
}
}
}