Skip to content

Commit 6b70752

Browse files
committed
Implemented JUnit tests for the main TheTVDBApi covering all available layouts. Introduced new "Procedure" throwable @FunctionalInterface for using void methods in lambda-expressions. Changed user-authentication check in API implementation to throw a APIPreconditionException as it's more of a precondition check rather than a parameter validation.
1 parent 1702b8e commit 6b70752

File tree

11 files changed

+884
-160
lines changed

11 files changed

+884
-160
lines changed

src/main/java/com/github/m0nk3y2k4/thetvdb/internal/api/impl/TheTVDBApiImpl.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import com.github.m0nk3y2k4.thetvdb.api.model.data.User;
2828
import com.github.m0nk3y2k4.thetvdb.internal.connection.APIConnection;
2929
import com.github.m0nk3y2k4.thetvdb.internal.connection.RemoteAPI;
30+
import com.github.m0nk3y2k4.thetvdb.internal.exception.APIPreconditionException;
3031
import com.github.m0nk3y2k4.thetvdb.internal.resource.impl.AuthenticationAPI;
3132
import com.github.m0nk3y2k4.thetvdb.internal.resource.impl.EpisodesAPI;
3233
import com.github.m0nk3y2k4.thetvdb.internal.resource.impl.LanguagesAPI;
@@ -566,7 +567,7 @@ public JsonNode addToRatings(@Nonnull String itemType, long itemId, long itemRat
566567
*/
567568
private void validateUserAuthentication() {
568569
if (!con.userAuthentication()) {
569-
throw new IllegalArgumentException("API call requires userKey/userName to be set!");
570+
throw new APIPreconditionException("API call requires userKey/userName to be set!");
570571
}
571572
}
572573
}

src/main/java/com/github/m0nk3y2k4/thetvdb/internal/util/functional/ThrowableFunctionalInterfaces.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,21 @@ static <T, R, X extends Exception> Function<T, R, X> of(java.util.function.Funct
8080
*/
8181
R apply(T t) throws X;
8282
}
83+
84+
/**
85+
* Procedure functional interface which allows the <em>{@code invoke}</em> method to throw an exception of type X. Can be used
86+
* for lambda-representations of a void method that may throw an exception.
87+
*
88+
* @param <X> the type of exception to be thrown by the procedure
89+
*/
90+
@FunctionalInterface
91+
interface Procedure<X extends Exception> {
92+
93+
/**
94+
* Invokes the procedure.
95+
*
96+
* @throws X When an exception occurred during invocation
97+
*/
98+
void invoke() throws X;
99+
}
83100
}

src/test/java/com/github/m0nk3y2k4/thetvdb/internal/api/impl/TheTVDBApiImplTest.java

Lines changed: 541 additions & 0 deletions
Large diffs are not rendered by default.

src/test/java/com/github/m0nk3y2k4/thetvdb/internal/resource/impl/SeriesAPITest.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import static com.github.m0nk3y2k4.thetvdb.internal.util.http.HttpRequestMethod.GET;
1616
import static com.github.m0nk3y2k4.thetvdb.internal.util.http.HttpRequestMethod.HEAD;
1717
import static com.github.m0nk3y2k4.thetvdb.testutils.APITestUtil.params;
18+
import static com.github.m0nk3y2k4.thetvdb.testutils.MockServerUtil.getHeadersFrom;
1819
import static com.github.m0nk3y2k4.thetvdb.testutils.MockServerUtil.jsonResponse;
1920
import static com.github.m0nk3y2k4.thetvdb.testutils.MockServerUtil.request;
2021
import static com.github.m0nk3y2k4.thetvdb.testutils.json.JSONTestUtil.JsonResource.ACTORS;
@@ -31,13 +32,10 @@
3132
import static org.assertj.core.api.Assertions.assertThat;
3233
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
3334
import static org.junit.jupiter.params.provider.Arguments.of;
34-
import static org.mockserver.model.Header.header;
3535
import static org.mockserver.model.HttpResponse.response;
3636
import static org.mockserver.model.Parameter.param;
3737

38-
import java.util.Map;
3938
import java.util.function.Supplier;
40-
import java.util.stream.Collectors;
4139
import java.util.stream.Stream;
4240

4341
import com.github.m0nk3y2k4.thetvdb.internal.api.impl.QueryParametersImpl;
@@ -58,8 +56,7 @@ class SeriesAPITest {
5856
@BeforeAll
5957
static void setUpRoutes(MockServerClient client) throws Exception {
6058
client.when(request("/series/84574", GET)).respond(jsonResponse(SERIES));
61-
client.when(request("/series/7451", HEAD)).respond(response().withHeaders(((Map<String, String>)SERIESHEADER.getDTO().getData())
62-
.entrySet().stream().map(e -> header(e.getKey(), e.getValue())).collect(Collectors.toList())));
59+
client.when(request("/series/7451", HEAD)).respond(response().withHeaders(getHeadersFrom(SERIESHEADER)));
6360
client.when(request("/series/36145/actors", GET)).respond(jsonResponse(ACTORS));
6461
client.when(request("/series/84674/episodes", GET)).respond(jsonResponse(EPISODES));
6562
client.when(request("/series/69547/episodes", GET, param("page", "4"))).respond(jsonResponse(EPISODES));

src/test/java/com/github/m0nk3y2k4/thetvdb/testutils/APITestUtil.java

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,28 @@
1212
public class APITestUtil {
1313

1414
/**
15-
* Creates a new query parameter representing the given key/value pair
15+
* Creates a new query parameter with the given key/value pair
1616
*
17-
* @param key The query parameter key
18-
* @param value The query parameter value
17+
* @param k1 The query parameter key
18+
* @param v1 The query parameter value
1919
*
2020
* @return New query parameter representing a single key/value pair
2121
*/
22-
public static QueryParameters params(String key, String value) {
23-
return new QueryParametersImpl().addParameter(key, value);
22+
public static QueryParameters params(String k1, String v1) {
23+
return new QueryParametersImpl().addParameter(k1, v1);
24+
}
25+
26+
/**
27+
* Creates a new query parameter with the given key/value pairs
28+
*
29+
* @param k1 The 1st query parameter key
30+
* @param v1 The 1st query parameter value
31+
* @param k2 The 2nd query parameter key
32+
* @param v2 The 2nd query parameter value
33+
*
34+
* @return New query parameter representing two key/value pairs
35+
*/
36+
public static QueryParameters params(String k1, String v1, String k2, String v2) {
37+
return params(k1, v1).addParameter(k2, v2);
2438
}
2539
}

src/test/java/com/github/m0nk3y2k4/thetvdb/testutils/MockServerUtil.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
import static org.mockserver.model.NottableString.not;
1414

1515
import java.io.IOException;
16+
import java.util.AbstractMap;
17+
import java.util.Map;
18+
import java.util.Objects;
19+
import java.util.function.Consumer;
20+
import java.util.function.Function;
1621

1722
import javax.annotation.Nonnull;
1823

@@ -93,6 +98,28 @@ public static Headers defaultAPIHttpHeaders(boolean withAuthorization) {
9398
withAuthorization ? header(ACCEPT_LANGUAGE, "^[a-z]{2}|[A-Z]{2}$") : header(not(ACCEPT_LANGUAGE)));
9499
}
95100

101+
/**
102+
* Tries to create a set of mock server headers from the given JSON resource object. For this, the resources {@link JsonResource#getDTO()}
103+
* method must return a {@link Map}. The key/value pairs of this map will be converted into their String representation and will be set
104+
* as key/value pairs on the returned headers object.
105+
*
106+
* @param resource The test resource based on which the headers object should be created
107+
*
108+
* @return Headers object with key/value pairs from the given JSON resource. In case the resources DTO is not compatible an
109+
* empty headers object without any keys or values will be returned.
110+
*/
111+
public static Headers getHeadersFrom(JsonResource resource) {
112+
Headers headers = new Headers();
113+
if (resource.getDTO().getData() instanceof Map) {
114+
Function<Map.Entry<?, ?>, Map.Entry<String, String>> toStringValues = e ->
115+
new AbstractMap.SimpleImmutableEntry<>(Objects.toString(e.getKey(), null), Objects.toString(e.getValue(), null));
116+
Consumer<Map.Entry<String, String>> addHeader = e -> headers.withEntry(e.getKey(), e.getValue());
117+
118+
((Map<?, ?>)resource.getDTO().getData()).entrySet().stream().map(toStringValues).forEach(addHeader);
119+
}
120+
return headers;
121+
}
122+
96123
/**
97124
* Creates a simple HTTP-200 <i>"OK"</i> response. The response body contains some dummy JSON success message.
98125
*
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package com.github.m0nk3y2k4.thetvdb.testutils.assertj;
2+
3+
import java.io.IOException;
4+
import java.util.Objects;
5+
import java.util.Optional;
6+
7+
import com.fasterxml.jackson.databind.JsonNode;
8+
import com.github.m0nk3y2k4.thetvdb.api.exception.APIException;
9+
import com.github.m0nk3y2k4.thetvdb.api.model.APIResponse;
10+
import com.github.m0nk3y2k4.thetvdb.testutils.json.JSONTestUtil;
11+
import com.github.m0nk3y2k4.thetvdb.testutils.parameterized.TestTheTVDBAPICall;
12+
import org.assertj.core.api.AbstractAssert;
13+
import org.mockserver.client.MockServerClient;
14+
import org.mockserver.model.HttpRequest;
15+
import org.mockserver.verify.VerificationTimes;
16+
17+
/**
18+
* Special assertion for {@link TestTheTVDBAPICall} objects, wrapping some API route invocation
19+
* <p><br>
20+
* Supports expectation matching for API routes, regardless of the actual API layout that is used. For wrapped non-void routes a
21+
* {@link JSONTestUtil.JsonResource} object is expected for matching. The assert will automatically try to determine the layout used
22+
* by the API route and will perform the matching operation accordingly.
23+
* <pre>{@code
24+
* TheTVDBApi api = TheTVDBApiFactory.createApi("Some APIKey");
25+
*
26+
* // Each assertion invokes the given (non-void) API route and matches the returned value with the given expectation (with automatic conversion)
27+
*
28+
* // TheTVDBApi layout: expectation automatically converted to ACTORS.getDTO().getData()
29+
* TestTheTVDBAPICallAssert.assertThat(route(() -> api.getActors(45), "Returning List<Actors>"))
30+
* .matchesExpectation(JsonResource.ACTORS);
31+
*
32+
* // Extended layout: expectation automatically converted to ACTORS.getDTO()
33+
* TestTheTVDBAPICallAssert.assertThat(route(() -> api.extended().getActors(45), "Returning APIResponse<List<Actor>>"))
34+
* .matchesExpectation(JsonResource.ACTORS);
35+
*
36+
* // JSON layout: expectation automatically converted to ACTORS.getJson()
37+
* TestTheTVDBAPICallAssert.assertThat(route(() -> api.json().getActors(45), "Returning JsonNode"))
38+
* .matchesExpectation(JsonResource.ACTORS);
39+
* }</pre>
40+
* Unfortunately void API routes do not return any object which could be used for matching an expectation. However, you may let the assert
41+
* verify that a specific resource has been invoked on the mock server by passing a corresponding HttpRequest object as expectation. For
42+
* this to work the assert must get in contact with the mock server running in the background. The server can be announced by setting a
43+
* mock server reference to the assert first.
44+
* <pre>{@code
45+
* @Test
46+
* void voidApiRouteTest(MockServerClient client) throws Exception { // Client can be injected when using the JUnit5 HttpsMockServerExtension
47+
* TheTVDBApi api = TheTVDBApiFactory.createApi("Some APIKey");
48+
*
49+
* // Make the mock server known to the assert with "usingMockServer(client)"
50+
* TestTheTVDBAPICallAssert.assertThat(route(() -> api.login(), "Void route not returning any object"))
51+
* .usingMockServer(client).matchesExpectation(HttpRequest.request("/api/login").withMethod("GET"));
52+
* }
53+
* } </pre>
54+
* @param <T> type of the wrapped routes actual return value
55+
*/
56+
public class TestTheTVDBAPICallAssert<T> extends AbstractAssert<TestTheTVDBAPICallAssert<T>, TestTheTVDBAPICall<T>> {
57+
58+
/** Reference to the mock server (for verifying resource invocations of void API routes). Has to be set via #usingMockServer first. */
59+
private MockServerClient client;
60+
61+
private TestTheTVDBAPICallAssert(TestTheTVDBAPICall<T> actual) {
62+
super(actual, TestTheTVDBAPICallAssert.class);
63+
}
64+
65+
/**
66+
* Creates a new instance of TestTheTVDBAPICallAssert
67+
*
68+
* @param actual The actual value
69+
*
70+
* @param <T> type of the wrapped routes actual return value
71+
*
72+
* @return The created assertion object
73+
*/
74+
public static <T> TestTheTVDBAPICallAssert<T> assertThat(TestTheTVDBAPICall<T> actual) {
75+
return new TestTheTVDBAPICallAssert<>(actual);
76+
}
77+
78+
/**
79+
* Use the given client to verify requests have been received by the mock server. Has to be called before matching a
80+
* HttpRequest expectation.
81+
*
82+
* @param client Client reference used for working with the mock server
83+
*
84+
* @return This assertion object
85+
*/
86+
public TestTheTVDBAPICallAssert<T> usingMockServer(MockServerClient client) {
87+
this.client = client;
88+
return this;
89+
}
90+
91+
/**
92+
* Invokes the actual API call and matches the given expectation which must be either
93+
* <ul>
94+
* <li>A {@link JSONTestUtil.JsonResource} object for non-void API routes<br>
95+
* The routes actual return value will be compared with the given object using automatic conversion</li>
96+
* <li>A {@link HttpRequest} object for void API routes<br>
97+
* After the route has been invoked the mock server will be asked to verify that a request matching the given
98+
* object has been received exactly once</li>
99+
* </ul>
100+
*
101+
* @param expected The expected JsonResource or the HttpRequest to be verified
102+
*
103+
* @throws IOException If an exception occurred while auto-converting a JsonResource object into it's JsonNode representation
104+
* @throws APIException If an exception occurred while invoking the actual API call of this assertion
105+
*/
106+
public void matchesExpectation(Object expected) throws IOException, APIException {
107+
isNotNull();
108+
109+
if (isVoidCallInvocation()) {
110+
verifyMockServerRouteInvoked((HttpRequest)expected);
111+
} else {
112+
matchesJsonResourceExpectation((JSONTestUtil.JsonResource)expected);
113+
}
114+
}
115+
116+
/**
117+
* Checks whether the actual API call represents a void API route
118+
*
119+
* @return True if the API call is an instance of {@link TestTheTVDBAPICall.Void}
120+
*/
121+
private boolean isVoidCallInvocation() {
122+
return actual instanceof TestTheTVDBAPICall.Void;
123+
}
124+
125+
/**
126+
* Invokes the actual API call and verifies that the given HttpRequest has been received exactly once by the mock server
127+
*
128+
* @param request The HttpRequest to be verified it has been invoked once
129+
*
130+
* @throws APIException If an exception occurred while invoking the actual API call of this assertion
131+
*/
132+
private void verifyMockServerRouteInvoked(HttpRequest request) throws APIException {
133+
if (client == null) {
134+
failWithMessage("Cannot verify HTTP request expectation due to missing mock server client. "
135+
+ "Please provide a valid mock server client via TestTheTVDBAPICallAssert#usingMockServer(client)");
136+
}
137+
138+
actual.invoke(); // Ignore return value as it is always "null" for void methods
139+
140+
client.verify(request, VerificationTimes.once());
141+
}
142+
143+
/**
144+
* Invokes the actual API call and verifies that its return value matches the given JSON resource. The JSON resource may
145+
* be auto-converted based on the current API call layout before comparing the values.
146+
*
147+
* @param resource JSON resource which is expected to be returned by the invocation of the actual API call
148+
*
149+
* @throws IOException If an exception occurred while auto-converting a JsonResource object into it's JsonNode representation
150+
* @throws APIException If an exception occurred while invoking the actual API call of this assertion
151+
*/
152+
private void matchesJsonResourceExpectation(JSONTestUtil.JsonResource resource) throws IOException, APIException {
153+
T result = actual.invoke();
154+
Object expected = buildExpectation(result, resource);
155+
156+
if (!Objects.equals(result, expected)) {
157+
failWithActualExpectedAndMessage(result, expected, "Expected to be equal");
158+
}
159+
}
160+
161+
/**
162+
* Auto-converts the given JSON resource to a representation matching the layout used by the API call. The layout will be determined
163+
* heuristically by analyzing the routes actual return value and compare it to the type of values typically returned by a specific layout.
164+
*
165+
* @param result The value returned by invoking the actual API call
166+
* @param resource JSON resource representing the value expected to be returned by the API call
167+
*
168+
* @return Representation of the given resource matching the used layout. This can either be a data object, an APIResponse DTO or a JSON representation.
169+
*
170+
* @throws IOException If an exception occurred while auto-converting a JsonResource object into it's JsonNode representation
171+
*/
172+
private Object buildExpectation(T result, JSONTestUtil.JsonResource resource) throws IOException {
173+
if (usingExtendedLayout(result)) {
174+
// Invocation of some (non-void) TheTVDBApi.Extended layout route -> These routes always return an APIResponse<DTO> object
175+
return resource.getDTO();
176+
} else if (usingJsonLayout(result)) {
177+
// Invocation of some (non-void) TheTVDBApi.JSON layout route -> These routes always return a JsonNode object
178+
return resource.getJson();
179+
} else {
180+
// Invocation of some (non-void) TheTVDBApi layout route -> These routes always return the actual content payload of the APIResponse<DTO>
181+
return resource.getDTO().getData();
182+
}
183+
}
184+
185+
/**
186+
* Checks whether the given object is a typical return value for the {@link com.github.m0nk3y2k4.thetvdb.api.TheTVDBApi.Extended} layout
187+
*
188+
* @param result The value to check
189+
*
190+
* @return True if the given value represents a class that is typically returned by the invocation of Extended layout API routes
191+
*/
192+
private boolean usingExtendedLayout(T result) {
193+
return Optional.ofNullable(result).map(Object::getClass).map(APIResponse.class::isAssignableFrom).orElse(false);
194+
}
195+
196+
/**
197+
* Checks whether the given object is a typical return value for the {@link com.github.m0nk3y2k4.thetvdb.api.TheTVDBApi.JSON} layout
198+
*
199+
* @param result The value to check
200+
*
201+
* @return True if the given value represents a class that is typically returned by the invocation of JSON layout API routes
202+
*/
203+
private boolean usingJsonLayout(T result) {
204+
return Optional.ofNullable(result).map(Object::getClass).map(JsonNode.class::isAssignableFrom).orElse(false);
205+
}
206+
}

src/test/java/com/github/m0nk3y2k4/thetvdb/testutils/json/JSONTestUtil.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,10 @@ public URL getUrl() {
122122
/**
123123
* Returns a DTO representation of this JSON resource
124124
*
125-
* @param <T> The actual type of the DTO
126-
*
127125
* @return DTO representation of this JSON resource
128126
*/
129-
@SuppressWarnings("unchecked")
130-
public <T> APIResponse<T> getDTO() {
131-
return (APIResponse<T>)dtoSupplier.get();
127+
public APIResponse<?> getDTO() {
128+
return dtoSupplier.get();
132129
}
133130

134131
@Override

0 commit comments

Comments
 (0)