1
0
Files
hs.hsadmin.ng/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java
T
2025-11-10 11:03:26 +01:00

523 lines
21 KiB
Java

package net.hostsharing.hsadminng.hs.scenarios;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.PathNotFoundException;
import io.restassured.http.ContentType;
import lombok.Getter;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.reflection.AnnotationFinder;
import org.apache.commons.collections4.map.LinkedMap;
import org.assertj.core.api.AbstractStringAssert;
import org.hibernate.AssertionFailure;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import jakarta.validation.constraints.NotNull;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import static java.net.URLEncoder.encode;
import static java.util.stream.Collectors.joining;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS;
import static net.hostsharing.hsadminng.test.DebuggerDetection.isDebuggerAttached;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.platform.commons.util.StringUtils.isBlank;
import static org.junit.platform.commons.util.StringUtils.isNotBlank;
public abstract class UseCase<T extends UseCase<?>> {
private static final HttpClient client = HttpClient.newHttpClient();
private static final int HTTP_TIMEOUT_SECONDS = 20; // FIXME: configurable in environment
protected final ObjectMapper objectMapper = new ObjectMapper();
protected final ScenarioTest testSuite;
private final TestReport testReport;
private final Map<String, Function<String, UseCase<?>>> requirements = new LinkedMap<>();
private final String resultAlias;
private final Map<String, Object> givenProperties = new LinkedHashMap<>();
private String nextTitle; // just temporary to override resultAlias for sub-use-cases
private String introduction;
public UseCase(final ScenarioTest testSuite) {
this(testSuite, getResultAliasFromProducesAnnotationInCallStack());
}
public UseCase(final ScenarioTest testSuite, final String resultAlias) {
this.testSuite = testSuite;
this.testReport = testSuite.testReport;
this.resultAlias = resultAlias;
if (resultAlias != null) {
testReport.printPara("### UseCase " + title(resultAlias));
}
}
public final void requires(final String alias, final Function<String, UseCase<?>> useCaseFactory) {
requirements.put(alias, useCaseFactory);
}
public final HttpResponse thenExpect(final HttpStatus expectedStatus) {
if (introduction != null) {
testReport.printPara(introduction);
}
testReport.printPara("### Given Properties");
testReport.printLine("""
| name | value |
|------|-------|""");
givenProperties.forEach((key, value) ->
testReport.printLine("| " + key + " | " + value.toString().replace("\n", "<br>") + " |"));
testReport.printLine("");
testReport.silent(() ->
requirements.forEach((alias, factory) -> {
final var resolvedAlias = ScenarioTest.resolve(alias, DROP_COMMENTS);
if (!ScenarioTest.containsAlias(resolvedAlias)) {
factory.apply(resolvedAlias).run().keepAs(resolvedAlias);
}
})
);
final var response = run(expectedStatus);
assertThat(response).as("use case implementation must return main response, never null").isNotNull();
if (!response.status.isError()) {
verify(response);
}
keepInProduceAlias(response);
resetProperties();
return response;
}
// this method is called by the test framework, override, but do not call from subclass
protected HttpResponse run(final HttpStatus expectedStatus) {
assertThat(expectedStatus).as("legacy signature only defined for HttpStatus.OK").isEqualTo(HttpStatus.OK);
return run();
};
// legacy signature for backwards compatibility, only called by above method
protected HttpResponse run() {return null;}
protected void verify(final HttpResponse response) {
}
public UseCase<T> introduction(final String introduction) {
this.introduction = introduction;
return this;
}
public final UseCase<T> given(final String propName, final Object propValue) {
givenProperties.put(propName, ScenarioTest.resolve(propValue == null ? null : propValue.toString(), TemplateResolver.Resolver.KEEP_COMMENTS));
ScenarioTest.putProperty(propName, propValue);
return this;
}
public final JsonTemplate usingJsonBody(final String jsonTemplate) {
return new JsonTemplate(jsonTemplate);
}
public final HttpResponse obtain(
final String title,
final Supplier<HttpResponse> http,
final Function<HttpResponse, String> extractor,
final String... extraInfo) {
return withTitle(title, () -> {
final var response = http.get().keep(extractor);
response.optionallyReportRequestAndResponse();
Arrays.stream(extraInfo).forEach(testReport::printPara);
return response;
});
}
public final HttpResponse obtain(final String alias, final Supplier<HttpResponse> httpCall, final String... extraInfo) {
return withTitle(alias, () -> {
final var response = httpCall.get().keep();
response.optionallyReportRequestAndResponse();
Arrays.stream(extraInfo).forEach(testReport::printPara);
return response;
});
}
public HttpResponse withTitle(final String resolvableTitle, final Supplier<HttpResponse> httpCall, final String... extraInfo) {
this.nextTitle = resolvableTitle;
final var response = httpCall.get();
response.optionallyReportRequestAndResponse();
Arrays.stream(extraInfo).forEach(testReport::printPara);
this.nextTitle = null;
return response;
}
@SneakyThrows
public final HttpResponse httpGet(
final String uriPathWithPlaceholder,
final Function<HttpRequest.Builder, HttpRequest.Builder> requestCustomizer) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholder, DROP_COMMENTS);
final var requestBuilder = HttpRequest.newBuilder()
.GET()
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
.timeout(seconds(HTTP_TIMEOUT_SECONDS));
final var customizedRequestBuilder = requestCustomizer.apply(requestBuilder);
final var request = customizedRequestBuilder.build();
final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.GET, uriPath, null, response);
}
@SneakyThrows
public final HttpResponse httpGet(final FakeLoginUser loginUser, final String uriPathWithPlaceholders) {
return httpGet(uriPathWithPlaceholders,
req -> req.header("Authorization", loginUser.bearer()));
}
@SneakyThrows
public final HttpResponse httpPost(
final FakeLoginUser loginUser, final String uriPathWithPlaceholders,
final JsonTemplate bodyJsonTemplate) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders, DROP_COMMENTS);
final var requestBody = bodyJsonTemplate.resolvePlaceholders();
final var request = HttpRequest.newBuilder()
.POST(BodyPublishers.ofString(requestBody))
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json")
.header("Authorization", loginUser.bearer())
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
.build();
final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.POST, uriPath, requestBody, response);
}
@SneakyThrows
public final HttpResponse httpPatch(
final FakeLoginUser loginUser, final String uriPathWithPlaceholders,
final JsonTemplate bodyJsonTemplate
) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders, DROP_COMMENTS);
final var requestBody = bodyJsonTemplate.resolvePlaceholders();
final var request = HttpRequest.newBuilder()
.method(HttpMethod.PATCH.toString(), BodyPublishers.ofString(requestBody))
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json")
.header("Authorization", loginUser.bearer())
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
.build();
final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.PATCH, uriPath, requestBody, response);
}
@SneakyThrows
public final HttpResponse httpDelete(final FakeLoginUser loginUser, final String uriPathWithPlaceholders) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders, DROP_COMMENTS);
final var request = HttpRequest.newBuilder()
.DELETE()
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json")
.header("Authorization", loginUser.bearer())
.timeout(seconds(HTTP_TIMEOUT_SECONDS))
.build();
final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.DELETE, uriPath, null, response);
}
protected PathAssertion path(final String path) {
return new PathAssertion(path);
}
@SafeVarargs
protected final void verify(
final String title,
final Supplier<UseCase.HttpResponse> http,
final Consumer<UseCase.HttpResponse>... assertions) {
withTitle(title, () -> {
final var response = http.get();
Arrays.stream(assertions).forEach(assertion -> assertion.accept(response));
return response;
});
}
public final UUID uuid(final String alias) {
return ScenarioTest.uuid(alias);
}
public String uriEncoded(final String text) {
return encode(ScenarioTest.resolve(text, DROP_COMMENTS), StandardCharsets.UTF_8);
}
public static class JsonTemplate {
private final String template;
private JsonTemplate(final String jsonTemplate) {
this.template = jsonTemplate;
}
String resolvePlaceholders() {
return ScenarioTest.resolve(template, DROP_COMMENTS);
}
}
private void keepInProduceAlias(final HttpResponse response) {
final var producedAlias = testSuite.takeProducedAlias();
if (response != null) {
producedAlias.ifPresent(response::keepAs);
}
}
private static Duration seconds(final int secondsIfNoDebuggerAttached) {
return isDebuggerAttached() ? Duration.ofHours(1) : Duration.ofSeconds(secondsIfNoDebuggerAttached);
}
private void resetProperties() {
givenProperties.forEach((propName, val) -> ScenarioTest.removeProperty(propName));
}
public final class HttpResponse {
private final HttpMethod httpMethod;
private final String uri;
private final String requestBody;
@Getter
private final java.net.http.HttpResponse<String> response;
@Getter
private final HttpStatus status;
@Getter
private UUID locationUuid;
private boolean reportGenerated = false;
private boolean reportGeneratedWithResponse = false;
@SneakyThrows
public HttpResponse(
final HttpMethod httpMethod,
final String uri,
final String requestBody,
final java.net.http.HttpResponse<String> response
) {
this.httpMethod = httpMethod;
this.uri = uri;
this.requestBody = requestBody;
this.response = response;
this.status = HttpStatus.valueOf(response.statusCode());
if (this.status == HttpStatus.CREATED) {
final var location = response.headers().firstValue("Location").orElseThrow();
assertThat(location).startsWith("http://localhost:");
locationUuid = UUID.fromString(location.substring(location.lastIndexOf('/') + 1));
}
}
public HttpResponse expecting(final HttpStatus httpStatus) {
optionallyReportRequestAndResponse();
assertThat(HttpStatus.valueOf(response.statusCode())).isEqualTo(httpStatus);
return this;
}
public HttpResponse expecting(final ContentType contentType) {
optionallyReportRequestAndResponse();
assertThat(response.headers().firstValue("content-type"))
.contains(contentType.toString());
return this;
}
public HttpResponse keep(final Function<HttpResponse, String> extractor) {
optionallyReportRequestAndResponse();
final var alias = nextTitle != null ? ScenarioTest.resolve(nextTitle, DROP_COMMENTS) : resultAlias;
assertThat(alias).as("cannot keep result, no alias found").isNotNull();
final var value = extractor.apply(this);
ScenarioTest.putAlias(alias, UUID.fromString(value));
return this;
}
public HttpResponse keepAs(final String alias) {
optionallyReportRequestAndResponse();
ScenarioTest.putAlias(nonNullAlias(alias), locationUuid);
return this;
}
public HttpResponse keep() {
optionallyReportRequestAndResponse();
final var alias = nextTitle != null ? ScenarioTest.resolve(nextTitle, DROP_COMMENTS) : resultAlias;
assertThat(alias).as("cannot keep result, no title or alias found for locationUuid: " + locationUuid).isNotNull();
return keepAs(alias);
}
@SneakyThrows
public HttpResponse expectArrayElements(final int expectedElementCount) {
optionallyReportRequestAndResponse();
final var rootNode = objectMapper.readTree(response.body());
assertThat(rootNode.isArray()).as("array expected, but got: " + response.body()).isTrue();
final var root = (List<?>) objectMapper.readValue(response.body(), new TypeReference<List<Object>>() {
});
assertThat(root.size()).as("unexpected number of array elements").isEqualTo(expectedElementCount);
return this;
}
@SneakyThrows
public HttpResponse expectObject() {
optionallyReportRequestAndResponse();
final var rootNode = objectMapper.readTree(response.body());
assertThat(rootNode.isArray()).as("object expected, but got array: " + response.body()).isFalse();
return this;
}
@SneakyThrows
public <V> V getFromBody(final String path) {
final var body = response.body();
final var resolvedPath = ScenarioTest.resolve(path, DROP_COMMENTS);
return JsonPath.parse(body).read(resolvedPath);
}
@NotNull
@SneakyThrows
public <V> JsonOptional<V> getFromBodyAsOptional(final String path) {
try {
return JsonOptional.ofValue(getFromBody(path));
} catch (final PathNotFoundException e) {
return JsonOptional.notGiven();
}
}
@SneakyThrows
public AbstractStringAssert<?> path(final String path) {
return assertThat(getFromBodyAsOptional(path).givenAsString());
}
public HttpResponse reportWithResponse() {
return reportRequestAndResponse(true);
}
@SneakyThrows
private HttpResponse reportRequestAndResponse(final boolean unconditionallyWithResponse) {
if (reportGenerated) {
throw new IllegalStateException("request report already generated");
}
// the title
if (nextTitle != null) {
testReport.printPara("### " + ScenarioTest.resolve(nextTitle, KEEP_COMMENTS));
} else if (resultAlias != null) {
testReport.printPara("### Create " + resultAlias);
} else if (testReport.isSilent()) {
testReport.printPara("### Untitled Section");
} else {
fail("please wrap the http...-call in the UseCase using `withTitle(...)`");
}
// the request
testReport.printLine("```");
testReport.printLine(httpMethod.name() + " " + uri);
testReport.printJson(requestBody);
// the response
testReport.printLine("=> status: " + status + " " + (locationUuid != null ? locationUuid : ""));
if (unconditionallyWithResponse || httpMethod == HttpMethod.GET || status.isError()) {
testReport.printJson(response.body());
this.reportGeneratedWithResponse = true;
}
testReport.printLine("```");
testReport.printLine("");
this.reportGenerated = true;
return this;
}
@SneakyThrows
private void optionallyReportRequestAndResponse() {
if (!reportGenerated) {
reportRequestAndResponse(false);
}
}
private void verifyResponseReported(final String action) {
if (!reportGenerated) {
throw new IllegalStateException("report not generated yet, but expected for `" + action + "`");
}
if (!reportGeneratedWithResponse) {
throw new IllegalStateException("report without response, but response report required for `" + action + "`");
}
}
private String nonNullAlias(final String alias) {
// This marker tag should not appear in the source-code, as here is nothing to fix.
// But if it appears in generated Markdown files, it should show up when that marker tag is searched.
final var onlyVisibleInGeneratedMarkdownNotInSource = new String(new char[]{'F', 'I', 'X', 'M', 'E'});
return alias == null ? "unknown alias -- " + onlyVisibleInGeneratedMarkdownNotInSource : alias;
}
public HttpResponse extractUuidAlias(final String jsonPath, final String resolvableName) {
verifyResponseReported("extractUuidAlias");
final var resolvedName = ScenarioTest.resolve(resolvableName, DROP_COMMENTS);
final var resolvedJsonPath = getFromBodyAsOptional(jsonPath).givenUUID();
ScenarioTest.putAlias(resolvedName, resolvedJsonPath);
return this;
}
public HttpResponse extractValue(final String jsonPath, final String resolvableName) {
verifyResponseReported("extractValue");
final var resolvedName = ScenarioTest.resolve(resolvableName, DROP_COMMENTS);
final var resolvedJsonPath = getFromBodyAsOptional(jsonPath).givenAsString();
ScenarioTest.putProperty(resolvedName, resolvedJsonPath);
return this;
}
}
protected T self() {
//noinspection unchecked
return (T) this;
}
private static @Nullable String getResultAliasFromProducesAnnotationInCallStack() {
return AnnotationFinder.findCallerAnnotation(Produces.class, Test.class)
.map(produces -> oneOf(produces.value(), produces.explicitly()))
.orElse(null);
}
private static String oneOf(final String one, final String another) {
if (isNotBlank(one) && isBlank(another)) {
return one;
} else if (isBlank(one) && isNotBlank(another)) {
return another;
}
throw new AssertionFailure("exactly one value required, but got '" + one + "' and '" + another + "'");
}
private String title(String resultAlias) {
return getClass().getSimpleName().replaceAll("([a-z])([A-Z]+)", "$1 $2") + " => " + resultAlias;
}
@Override
public String toString() {
final var properties = givenProperties.entrySet().stream()
.map(e -> "\t" + e.getKey() + "=" + e.getValue())
.collect(joining("\n"));
return getClass().getSimpleName() + "(\n\t" + properties + "\n)";
}
}