feature/use-case-acceptance-tests #116

Merged
hsh-michaelhoennig merged 49 commits from feature/use-case-acceptance-tests into master 2024-10-30 11:40:46 +01:00
7 changed files with 179 additions and 154 deletions
Showing only changes of commit ccb810f314 - Show all commits

View File

@ -1,6 +1,5 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import lombok.Getter;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository;
@ -10,14 +9,10 @@ import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.apache.commons.collections4.SetUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.TestInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.HashSet;
@ -36,16 +31,19 @@ public abstract class ScenarioTest extends ContextBasedTest {
final static String RUN_AS_USER = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented
@Getter
private PrintWriter markdownFile;
record Alias<T extends UseCase<T>>(Class<T> useCase, UUID uuid) {
private StringBuilder debugLog = new StringBuilder();
record Alias<T extends UseCase<T>>(Class<T> useCase, UUID uuid) {}
@Override
public String toString() {
return uuid.toString();
}
}
private final static Map<String, Alias<?>> aliases = new HashMap<>();
private final static Map<String, Object> properties = new HashMap<>();
public final TestReport testReport = new TestReport(aliases);
@LocalServerPort
Integer port;
@ -61,43 +59,16 @@ public abstract class ScenarioTest extends ContextBasedTest {
createHostsharingPerson();
try {
testInfo.getTestMethod().ifPresent(this::callRequiredProducers);
createTestLogMarkdownFile(testInfo);
testReport.createTestLogMarkdownFile(testInfo);
} catch (Exception exc) {
throw exc;
}
}
@AfterEach
void cleanup() {
void cleanup() { // final TestInfo testInfo
properties.clear();
if (markdownFile != null) {
markdownFile.close();
} else {
toString();
}
debugLog = new StringBuilder();
}
@SneakyThrows
void print(final String output) {
final var outputWithCommentsForUuids = UUIDAppender.appendUUIDKey(aliases, output.replace("+", "\\+"));
// for tests executed due to @Requires/@Produces there is no markdownFile yet
if (markdownFile != null) {
markdownFile.print(outputWithCommentsForUuids);
}
// but the debugLog should contain all output
debugLog.append(outputWithCommentsForUuids);
}
void printLine(final String output) {
print(output + "\n");
}
void printPara(final String output) {
printLine("\n" +output + "\n");
testReport.close();
}
private void createHostsharingPerson() {
@ -117,13 +88,6 @@ public abstract class ScenarioTest extends ContextBasedTest {
);
}
private void createTestLogMarkdownFile(final TestInfo testInfo) throws IOException {
final var testMethodName = testInfo.getTestMethod().map(Method::getName).orElseThrow();
final var testMethodOrder = testInfo.getTestMethod().map(m -> m.getAnnotation(Order.class).value()).orElseThrow();
markdownFile = new PrintWriter(new FileWriter("doc/scenarios/" + testMethodOrder + "-" + testMethodName + ".md"));
print("## Scenario: " + testMethodName.replaceAll("([a-z])([A-Z]+)", "$1 $2"));
}
@SneakyThrows
private void callRequiredProducers(final Method currentTestMethod) {
final var testMethodRequired = Optional.of(currentTestMethod)

View File

@ -0,0 +1,84 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.TestInfo;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
public class TestReport {
private final Map<String, ?> aliases;
private final StringBuilder debugLog = new StringBuilder(); // records everything for debugging purposes
private PrintWriter markdownFile;
private int silent; // do not print anything to test-report if >0
public TestReport(final Map<String, ?> aliases) {
this.aliases = aliases;
}
public void createTestLogMarkdownFile(final TestInfo testInfo) throws IOException {
final var testMethodName = testInfo.getTestMethod().map(Method::getName).orElseThrow();
final var testMethodOrder = testInfo.getTestMethod().map(m -> m.getAnnotation(Order.class).value()).orElseThrow();
assertThat(new File("doc/scenarios/").isDirectory() || new File("doc/scenarios/").mkdirs()).as("mkdir doc/scenarios/").isTrue();
markdownFile = new PrintWriter(new FileWriter("doc/scenarios/" + testMethodOrder + "-" + testMethodName + ".md"));
print("## Scenario: " + testMethodName.replaceAll("([a-z])([A-Z]+)", "$1 $2"));
}
@SneakyThrows
public void print(final String output) {
final var outputWithCommentsForUuids = appendUUIDKey(output.replace("+", "\\+"));
// for tests executed due to @Requires/@Produces there is no markdownFile yet
if (markdownFile != null && silent == 0) { // FIXME: check if markdownFile can even be null
markdownFile.print(outputWithCommentsForUuids);
}
// but the debugLog should contain all output, even if silent
debugLog.append(outputWithCommentsForUuids);
}
public void printLine(final String output) {
print(output + "\n");
}
public void printPara(final String output) {
printLine("\n" +output + "\n");
}
public void close() {
markdownFile.close();
}
private String appendUUIDKey(String multilineText) {
final var lines = multilineText.split("\\r?\\n");
final var result = new StringBuilder();
for (String line : lines) {
for (Map.Entry<String, ?> entry : aliases.entrySet()) {
final var uuidString = entry.getValue().toString();
if (line.contains(uuidString)) {
line = line + " // " + entry.getKey();
break; // only add comment for one UUID per row (in our case, there is only one per row)
}
}
result.append(line).append("\n");
}
return result.toString();
}
void silent(final Runnable code) {
silent++;
code.run();
silent--;
}
}

View File

@ -1,34 +0,0 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import java.util.Map;
public class UUIDAppender {
public static String appendUUIDKey(Map<String, ScenarioTest.Alias<?>> uuidMap, String multilineText) {
// Split the multiline text into lines
String[] lines = multilineText.split("\\r?\\n");
// StringBuilder to build the resulting multiline text
StringBuilder result = new StringBuilder();
// Iterate over each line
for (String line : lines) {
// Iterate over the map to find if the line contains a UUID
for (Map.Entry<String, ScenarioTest.Alias<?>> entry : uuidMap.entrySet()) {
String uuidString = entry.getValue().uuid().toString();
// If the line contains the UUID, append the key at the end
if (line.contains(uuidString)) {
line = line + " // " + entry.getKey();
break; // Exit once we've matched one UUID
}
}
// Append the (possibly modified) line to the result
result.append(line).append("\n");
}
// Return the final modified text
return result.toString();
}
}

View File

@ -20,6 +20,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -35,13 +36,15 @@ import static org.junit.platform.commons.util.StringUtils.isNotBlank;
public abstract class UseCase<T extends UseCase<?>> {
private static final HttpClient client = HttpClient.newHttpClient();
final ObjectMapper objectMapper = new ObjectMapper();
private 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; // FIXME: ugly
private String nextTitle; // just temporary
public UseCase(final ScenarioTest testSuite) {
this(testSuite, getResultAliasFromProducesAnnotationInCallStack());
@ -49,9 +52,10 @@ public abstract class UseCase<T extends UseCase<?>> {
public UseCase(final ScenarioTest testSuite, final String resultAlias) {
this.testSuite = testSuite;
this.testReport = testSuite.testReport;
this.resultAlias = resultAlias;
if (resultAlias != null) {
printPara("### UseCase " + title(resultAlias));
testReport.printPara("### UseCase " + title(resultAlias));
}
}
@ -62,19 +66,23 @@ public abstract class UseCase<T extends UseCase<?>> {
}
public final HttpResponse doRun() {
printPara("### Given Properties");
printLine("""
testReport.printPara("### Given Properties");
testReport.printLine("""
| name | value |
|------|-------|""");
givenProperties.forEach((key, value) -> printLine("| " + key + " | " + value.toString().replace("\n", "<br>") + " |"));
printLine("");
givenProperties.forEach((key, value) ->
testReport.printLine("| " + key + " | " + value.toString().replace("\n", "<br>") + " |"));
testReport.printLine("");
testReport.silent(() ->
requirements.forEach((alias, factory) -> {
if (!ScenarioTest.containsAlias(alias)) {
factory.apply(alias).run().keep();
}
});
})
);
return run();
}
protected abstract HttpResponse run();
public final UseCase<T> given(final String propName, final Object propValue) {
@ -87,15 +95,27 @@ public abstract class UseCase<T extends UseCase<?>> {
return new JsonTemplate(jsonTemplate);
}
public final void keep(final String alias, final Supplier<HttpResponse> http, final Function<HttpResponse, String> extractor) {
this.nextTitle = ScenarioTest.resolve(alias);
public final void keep(
final String alias,
final Supplier<HttpResponse> http,
final Function<HttpResponse, String> extractor,
final String... extraInfo) {
withTitle(ScenarioTest.resolve(alias), () -> {
http.get().keep(extractor);
this.nextTitle = null;
Arrays.stream(extraInfo).forEach(testReport::printPara);
});
}
public final void keep(final String alias, final Supplier<HttpResponse> http) {
this.nextTitle = ScenarioTest.resolve(alias);
public final void keep(final String alias, final Supplier<HttpResponse> http, final String... extraInfo) {
withTitle(ScenarioTest.resolve(alias), () -> {
http.get().keep();
Arrays.stream(extraInfo).forEach(testReport::printPara);
});
}
private void withTitle(final String title, final Runnable code) {
this.nextTitle = title;
code.run();
this.nextTitle = null;
}
@ -199,21 +219,23 @@ public abstract class UseCase<T extends UseCase<?>> {
}
if (nextTitle != null) {
printLine("\n### " + nextTitle + "\n");
testReport.printLine("\n### " + nextTitle + "\n");
} else if (resultAlias != null) {
printLine("\n### " + resultAlias + "\n");
testReport.printLine("\n### " + resultAlias + "\n");
}
printLine("```");
printLine(httpMethod.name() + " " + uri);
printLine((requestBody != null ? requestBody : "") + "=> status: " + status + " " +
// FIXME: maybe refactor into TestReport?
testReport.printLine("```");
testReport.printLine(httpMethod.name() + " " + uri);
testReport.printLine((requestBody != null ? requestBody : "") + "=> status: " + status + " " +
(locationUuid != null ? locationUuid : ""));
if (httpMethod == HttpMethod.GET || !status.is2xxSuccessful()) {
final var jsonNode = objectMapper.readTree(response.body());
final var prettyJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode);
printLine(prettyJson);
testReport.printLine(prettyJson);
}
printLine("```");
printLine("");
testReport.printLine("```");
testReport.printLine("");
}
public HttpResponse expecting(final HttpStatus httpStatus) {
@ -262,18 +284,6 @@ public abstract class UseCase<T extends UseCase<?>> {
}
}
public void print(final String output) {
testSuite.print(output);
}
public void printLine(final String output) {
testSuite.printLine(output);
}
public void printPara(final String output) {
testSuite.printPara(output);
}
protected T self() {
//noinspection unchecked
return (T) this;
@ -288,7 +298,7 @@ public abstract class UseCase<T extends UseCase<?>> {
private static String oneOf(final String one, final String another) {
if (isNotBlank(one) && isBlank(another)) {
return one;
} else if ( isBlank(one) && isNotBlank(another)) {
} else if (isBlank(one) && isNotBlank(another)) {
return another;
}
throw new AssertionFailure("exactly one value required, but got '" + one + "' and '" + another + "'");

View File

@ -18,10 +18,10 @@ public class CreateSelfDebitorForPartner extends UseCase<CreateSelfDebitorForPar
keep("partnerPersonUuid", () ->
httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].holder.uuid")
response -> response.expectArrayElements(1).getFromBody("[0].holder.uuid"),
"From that output above, we're taking the UUID of the holder of the first result element.",
"**HINT**: With production data, you might get multiple results and have to decide which is the right one."
);
printPara("From that output above, we're taking the UUID of the holder of the first result element.");
printPara("**HINT**: With production data, you might get multiple results and have to decide which is the right one.");
keep("BankAccount: Test AG - refund bank account", () ->
httpPost("/api/hs/office/bankaccounts", usingJsonBody("""

View File

@ -1,8 +1,8 @@
package net.hostsharing.hsadminng.hs.office.scenarios.partner;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
@ -16,7 +16,8 @@ public class AddOperationsContactToPartner extends UseCase<AddOperationsContactT
@Override
protected HttpResponse run() {
keep("Person: %{operationsContactGivenName} %{operationsContactFamilyName}", () ->
keep("Person: %{operationsContactGivenName} %{operationsContactFamilyName}",
() ->
httpPost("/api/hs/office/persons", usingJsonBody("""
{
"personType": "NATURAL_PERSON",
@ -24,10 +25,10 @@ public class AddOperationsContactToPartner extends UseCase<AddOperationsContactT
"givenName": ${operationsContactGivenName}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON),
"Please check first if that person already exists, if so, use it's UUID below.",
"**HINT**: operations contacts are always connected to a partner-person, thus a person which is a holder of a partner-relation."
);
printPara("Please check first if that person already exists, if so, use it's UUID below.");
printPara("**HINT**: operations contacts are always connected to a partner-person, thus a person which is a holder of a partner-relation.");
keep("Contact: %{operationsContactGivenName} %{operationsContactFamilyName}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody("""
@ -41,9 +42,9 @@ public class AddOperationsContactToPartner extends UseCase<AddOperationsContactT
}
}
"""))
.expecting(CREATED).expecting(JSON)
.expecting(CREATED).expecting(JSON),
"Please check first if that contact already exists, if so, use it's UUID below."
);
printPara("Please check first if that contact already exists, if so, use it's UUID below.");
return httpPost("/api/hs/office/relations", usingJsonBody("""
{

View File

@ -24,10 +24,10 @@ public class AddRepresentativeToPartner extends UseCase<AddRepresentativeToPartn
"givenName": ${representativeGivenName}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON),
"Please check first if that person already exists, if so, use it's UUID below.",
"**HINT**: A representative is always a natural person and represents a non-natural-person."
);
printPara("Please check first if that person already exists, if so, use it's UUID below.");
printPara("**HINT**: A representative is always a natural person and represents a non-natural-person.");
keep("Contact: %{representativeGivenName} %{representativeFamilyName}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody("""
@ -42,9 +42,9 @@ public class AddRepresentativeToPartner extends UseCase<AddRepresentativeToPartn
}
}
"""))
.expecting(CREATED).expecting(JSON)
.expecting(CREATED).expecting(JSON),
"Please check first if that contact already exists, if so, use it's UUID below."
);
printPara("Please check first if that contact already exists, if so, use it's UUID below.");
return httpPost("/api/hs/office/relations", usingJsonBody("""
{