TCK test for spec clarification (#1089): Client MUST throw *most specific* subclass of WebApplicationException
diff --git a/jaxrs-tck/src/main/java/ee/jakarta/tck/ws/rs/common/matchers/IsThrowingMatcher.java b/jaxrs-tck/src/main/java/ee/jakarta/tck/ws/rs/common/matchers/IsThrowingMatcher.java
new file mode 100644
index 0000000..c6b067d
--- /dev/null
+++ b/jaxrs-tck/src/main/java/ee/jakarta/tck/ws/rs/common/matchers/IsThrowingMatcher.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2022 Jeremias Weber. All rights reserved.
+ *
+ * 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 ee.jakarta.tck.ws.rs.common.matchers;
+
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+/**
+ * Custom Hamcrest Matcher to assert if the given exception is thrown.
+ *
+ * @author Jeremias Weber
+ */
+public class IsThrowingMatcher extends TypeSafeMatcher<Runnable> {
+
+    private final Class<? extends Exception> expected;
+    private Class<? extends Exception> actual;
+
+    public IsThrowingMatcher(final Class<? extends Exception> expected) {
+        this.expected = expected;
+    }
+
+    public static Matcher<Runnable> isThrowing(final Class<? extends Exception> expected) {
+        return new IsThrowingMatcher(expected);
+    }
+
+    @Override
+    public boolean matchesSafely(final Runnable action) {
+        try {
+            action.run();
+            return false;
+        } catch (Exception e) {
+            actual = e.getClass();
+            return this.expected.isInstance(e);
+        }
+    }
+
+    @Override
+    public void describeTo(final Description description) {
+        description.appendText(expected.getName())
+                .appendText(" but: was ").appendText(actual.getName());
+    }
+}
diff --git a/jaxrs-tck/src/main/java/ee/jakarta/tck/ws/rs/spec/client/exceptions/ClientExceptionsIT.java b/jaxrs-tck/src/main/java/ee/jakarta/tck/ws/rs/spec/client/exceptions/ClientExceptionsIT.java
new file mode 100644
index 0000000..e6c8cc9
--- /dev/null
+++ b/jaxrs-tck/src/main/java/ee/jakarta/tck/ws/rs/spec/client/exceptions/ClientExceptionsIT.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright (c) 2022 Jeremias Weber. All rights reserved.
+ *
+ * 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 ee.jakarta.tck.ws.rs.spec.client.exceptions;
+
+import static ee.jakarta.tck.ws.rs.common.matchers.IsThrowingMatcher.isThrowing;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.jboss.arquillian.container.test.api.Deployment;
+import org.jboss.arquillian.junit5.ArquillianExtension;
+import org.jboss.arquillian.test.api.ArquillianResource;
+import org.jboss.shrinkwrap.api.ShrinkWrap;
+import org.jboss.shrinkwrap.api.spec.WebArchive;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import jakarta.ws.rs.ApplicationPath;
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.ClientErrorException;
+import jakarta.ws.rs.ForbiddenException;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.InternalServerErrorException;
+import jakarta.ws.rs.NotAcceptableException;
+import jakarta.ws.rs.NotAllowedException;
+import jakarta.ws.rs.NotAuthorizedException;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.NotSupportedException;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.RedirectionException;
+import jakarta.ws.rs.ServerErrorException;
+import jakarta.ws.rs.ServiceUnavailableException;
+import jakarta.ws.rs.WebApplicationException;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientBuilder;
+import jakarta.ws.rs.core.Application;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.Response.Status;
+import jakarta.ws.rs.core.UriBuilder;
+
+/**
+ * Compliance Tests for exceptions thrown by {@link Client}.
+ *
+ * @author Jeremias Weber
+ * @since 4.0
+ */
+@Timeout(value = 1, unit = TimeUnit.HOURS)
+@ExtendWith(ArquillianExtension.class)
+public class ClientExceptionsIT {
+
+    private static Client client;
+
+    @ArquillianResource
+    private URL baseUrl;
+
+    @Deployment(testable = false)
+    public static WebArchive createDeployment() {
+        // given
+        final WebArchive archive = ShrinkWrap.create(WebArchive.class, "jaxrs_client_exceptions.war");
+        archive.addClasses(ClientExceptionsIT.class);
+        return archive;
+    }
+
+    /**
+     * Compliance Test for the spec requirement that {@link Client} MUST throw the
+     * <strong>most specific</strong> subclass of {@link WebApplicationException} for the returned status code.
+     */
+    @Test
+    public final void shouldThrowMostSpecificWebApplicationException() {
+        // Verifies that of RedirectionException is thrown if the server returns a 3XX status code.
+        assertThat(
+                // when
+                () -> client.target(uriForStatusCode(345, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(RedirectionException.class)
+        );
+
+        // Verifies that of ClientErrorException is thrown if the server returns a 4XX status code.
+        assertThat(
+                // when
+                () -> client.target(uriForStatusCode(456, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(ClientErrorException.class)
+        );
+
+        // Verifies that of BadRequestException is thrown if the server returns the status code 400.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.BAD_REQUEST, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(BadRequestException.class)
+        );
+
+        // Verifies that of NotAuthorizedException is thrown if the server returns the status code 401.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.UNAUTHORIZED, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(NotAuthorizedException.class)
+        );
+
+        // Verifies that of ForbiddenException is thrown if the server returns the status code 403.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.FORBIDDEN, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(ForbiddenException.class)
+        );
+
+        // Verifies that of NotFoundException is thrown if the server returns the status code 404.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.NOT_FOUND, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(NotFoundException.class)
+        );
+
+        // Verifies that of NotAllowedException is thrown if the server returns the status code 405.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.METHOD_NOT_ALLOWED, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(NotAllowedException.class)
+        );
+
+        // Verifies that of NotAcceptableException is thrown if the server returns the status code 406.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.NOT_ACCEPTABLE, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(NotAcceptableException.class)
+        );
+
+        // Verifies that of NotSupportedException is thrown if the server returns the status code 415.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.UNSUPPORTED_MEDIA_TYPE, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(NotSupportedException.class)
+        );
+
+        // Verifies that of ServerErrorException is thrown if the server returns a 5XX status code.
+        assertThat(
+                // when
+                () -> client.target(uriForStatusCode(567, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(ServerErrorException.class)
+        );
+
+        // Verifies that of InternalServerErrorException is thrown if the server returns the status code 500.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.INTERNAL_SERVER_ERROR, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(InternalServerErrorException.class)
+        );
+
+        // Verifies that of ServiceUnavailableException is thrown if the server returns the status code 503.
+        assertThat(
+                // when
+                () -> client.target(uriForStatus(Status.SERVICE_UNAVAILABLE, baseUrl)).request().get(String.class),
+                // then
+                isThrowing(ServiceUnavailableException.class)
+        );
+    }
+
+    @BeforeAll
+    static void createClient() {
+        ClientExceptionsIT.client = ClientBuilder.newClient();
+    }
+
+    @AfterAll
+    static void disposeClient() {
+        ClientExceptionsIT.client.close();
+    }
+
+    @ApplicationPath("")
+    private static final class StatusApplication extends Application {
+
+        @Override
+        public Set<Class<?>> getClasses() {
+            return Set.of(StatusResource.class);
+        }
+
+        @Path("status")
+        public static class StatusResource {
+
+            @GET
+            @Path("{statusCode}")
+            public Response respondWithStatusCode(@PathParam("statusCode") final int statusCode) {
+                return Response.status(statusCode).build();
+            }
+        }
+    }
+
+    private static final URI uriForStatus(Status status, URL baseUrl) {
+        return uriForStatusCode(status.getStatusCode(), baseUrl);
+    }
+
+    private static final URI uriForStatusCode(int statusCode, URL baseUrl) {
+        try {
+            return UriBuilder.fromUri(baseUrl.toURI())
+                    .path("status/{statusCode}")
+                    .build(statusCode);
+        } catch (URISyntaxException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+}
diff --git a/jaxrs-tck/src/test/java/jakarta/ws/rs/tck/common/matchers/IsThrowingMatcherTest.java b/jaxrs-tck/src/test/java/jakarta/ws/rs/tck/common/matchers/IsThrowingMatcherTest.java
new file mode 100644
index 0000000..b512c6e
--- /dev/null
+++ b/jaxrs-tck/src/test/java/jakarta/ws/rs/tck/common/matchers/IsThrowingMatcherTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2022 Jeremias Weber. All rights reserved.
+ *
+ * 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 jakarta.ws.rs.tck.common.matchers;
+
+import org.junit.jupiter.api.Test;
+
+import static ee.jakarta.tck.ws.rs.common.matchers.IsThrowingMatcher.isThrowing;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.not;
+
+final class IsThrowingMatcherTest {
+
+    @Test 
+    final void shouldMatchIfIsInstance() {
+        assertThat(
+                // when
+                () -> { throw new ChildException(); }, 
+                // then
+                isThrowing(ParentException.class)
+        );
+    }
+
+    @Test
+    final void shouldNotMatchIfIsNotInstance() {
+        assertThat(
+                // when
+                () -> { throw new ParentException(); }, 
+                // then
+                not(isThrowing(ChildException.class))
+        );
+    }
+
+    @Test
+    final void shouldNotMatchIfNothingThrown() {
+        assertThat(
+                // when
+                () -> {},
+                // then
+                not(isThrowing(Exception.class))
+        );
+    }
+
+    private static class ParentException extends RuntimeException { }
+
+    private static final class ChildException extends ParentException { }
+    
+}