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; package net.hostsharing.hsadminng.hs.office.scenarios;
import lombok.Getter;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; 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.apache.commons.collections4.SetUtils;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestInfo;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.server.LocalServerPort; 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.lang.reflect.Method;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; 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 final static String RUN_AS_USER = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented
@Getter record Alias<T extends UseCase<T>>(Class<T> useCase, UUID uuid) {
private PrintWriter markdownFile;
private StringBuilder debugLog = new StringBuilder(); @Override
public String toString() {
record Alias<T extends UseCase<T>>(Class<T> useCase, UUID uuid) {} return uuid.toString();
}
}
private final static Map<String, Alias<?>> aliases = new HashMap<>(); private final static Map<String, Alias<?>> aliases = new HashMap<>();
private final static Map<String, Object> properties = new HashMap<>(); private final static Map<String, Object> properties = new HashMap<>();
public final TestReport testReport = new TestReport(aliases);
@LocalServerPort @LocalServerPort
Integer port; Integer port;
@ -61,43 +59,16 @@ public abstract class ScenarioTest extends ContextBasedTest {
createHostsharingPerson(); createHostsharingPerson();
try { try {
testInfo.getTestMethod().ifPresent(this::callRequiredProducers); testInfo.getTestMethod().ifPresent(this::callRequiredProducers);
createTestLogMarkdownFile(testInfo); testReport.createTestLogMarkdownFile(testInfo);
} catch (Exception exc) { } catch (Exception exc) {
throw exc; throw exc;
} }
} }
@AfterEach @AfterEach
void cleanup() { void cleanup() { // final TestInfo testInfo
properties.clear(); properties.clear();
if (markdownFile != null) { testReport.close();
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");
} }
private void createHostsharingPerson() { 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 @SneakyThrows
private void callRequiredProducers(final Method currentTestMethod) { private void callRequiredProducers(final Method currentTestMethod) {
final var testMethodRequired = Optional.of(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.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers; import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration; import java.time.Duration;
import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -35,13 +36,15 @@ import static org.junit.platform.commons.util.StringUtils.isNotBlank;
public abstract class UseCase<T extends UseCase<?>> { public abstract class UseCase<T extends UseCase<?>> {
private static final HttpClient client = HttpClient.newHttpClient(); private static final HttpClient client = HttpClient.newHttpClient();
final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
protected final ScenarioTest testSuite; protected final ScenarioTest testSuite;
private final TestReport testReport;
private final Map<String, Function<String, UseCase<?>>> requirements = new LinkedMap<>(); private final Map<String, Function<String, UseCase<?>>> requirements = new LinkedMap<>();
private final String resultAlias; private final String resultAlias;
private final Map<String, Object> givenProperties = new LinkedHashMap<>(); private final Map<String, Object> givenProperties = new LinkedHashMap<>();
private String nextTitle; // FIXME: ugly
private String nextTitle; // just temporary
public UseCase(final ScenarioTest testSuite) { public UseCase(final ScenarioTest testSuite) {
this(testSuite, getResultAliasFromProducesAnnotationInCallStack()); this(testSuite, getResultAliasFromProducesAnnotationInCallStack());
@ -49,9 +52,10 @@ public abstract class UseCase<T extends UseCase<?>> {
public UseCase(final ScenarioTest testSuite, final String resultAlias) { public UseCase(final ScenarioTest testSuite, final String resultAlias) {
this.testSuite = testSuite; this.testSuite = testSuite;
this.testReport = testSuite.testReport;
this.resultAlias = resultAlias; this.resultAlias = resultAlias;
if (resultAlias != null) { 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() { public final HttpResponse doRun() {
printPara("### Given Properties"); testReport.printPara("### Given Properties");
printLine(""" testReport.printLine("""
| name | value | | name | value |
|------|-------|"""); |------|-------|""");
givenProperties.forEach((key, value) -> printLine("| " + key + " | " + value.toString().replace("\n", "<br>") + " |")); givenProperties.forEach((key, value) ->
printLine(""); testReport.printLine("| " + key + " | " + value.toString().replace("\n", "<br>") + " |"));
requirements.forEach((alias, factory) -> { testReport.printLine("");
if (!ScenarioTest.containsAlias(alias)) { testReport.silent(() ->
factory.apply(alias).run().keep(); requirements.forEach((alias, factory) -> {
} if (!ScenarioTest.containsAlias(alias)) {
}); factory.apply(alias).run().keep();
}
})
);
return run(); return run();
} }
protected abstract HttpResponse run(); protected abstract HttpResponse run();
public final UseCase<T> given(final String propName, final Object propValue) { 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); return new JsonTemplate(jsonTemplate);
} }
public final void keep(final String alias, final Supplier<HttpResponse> http, final Function<HttpResponse, String> extractor) { public final void keep(
this.nextTitle = ScenarioTest.resolve(alias); final String alias,
http.get().keep(extractor); final Supplier<HttpResponse> http,
this.nextTitle = null; final Function<HttpResponse, String> extractor,
final String... extraInfo) {
withTitle(ScenarioTest.resolve(alias), () -> {
http.get().keep(extractor);
Arrays.stream(extraInfo).forEach(testReport::printPara);
});
} }
public final void keep(final String alias, final Supplier<HttpResponse> http) { public final void keep(final String alias, final Supplier<HttpResponse> http, final String... extraInfo) {
this.nextTitle = ScenarioTest.resolve(alias); withTitle(ScenarioTest.resolve(alias), () -> {
http.get().keep(); 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; this.nextTitle = null;
} }
@ -199,21 +219,23 @@ public abstract class UseCase<T extends UseCase<?>> {
} }
if (nextTitle != null) { if (nextTitle != null) {
printLine("\n### " + nextTitle + "\n"); testReport.printLine("\n### " + nextTitle + "\n");
} else if (resultAlias != null) { } else if (resultAlias != null) {
printLine("\n### " + resultAlias + "\n"); testReport.printLine("\n### " + resultAlias + "\n");
} }
printLine("```");
printLine(httpMethod.name() + " " + uri); // FIXME: maybe refactor into TestReport?
printLine((requestBody != null ? requestBody : "") + "=> status: " + status + " " + testReport.printLine("```");
testReport.printLine(httpMethod.name() + " " + uri);
testReport.printLine((requestBody != null ? requestBody : "") + "=> status: " + status + " " +
(locationUuid != null ? locationUuid : "")); (locationUuid != null ? locationUuid : ""));
if (httpMethod == HttpMethod.GET || !status.is2xxSuccessful()) { if (httpMethod == HttpMethod.GET || !status.is2xxSuccessful()) {
final var jsonNode = objectMapper.readTree(response.body()); final var jsonNode = objectMapper.readTree(response.body());
final var prettyJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); final var prettyJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode);
printLine(prettyJson); testReport.printLine(prettyJson);
} }
printLine("```"); testReport.printLine("```");
printLine(""); testReport.printLine("");
} }
public HttpResponse expecting(final HttpStatus httpStatus) { 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() { protected T self() {
//noinspection unchecked //noinspection unchecked
return (T) this; return (T) this;
@ -288,7 +298,7 @@ public abstract class UseCase<T extends UseCase<?>> {
private static String oneOf(final String one, final String another) { private static String oneOf(final String one, final String another) {
if (isNotBlank(one) && isBlank(another)) { if (isNotBlank(one) && isBlank(another)) {
return one; return one;
} else if ( isBlank(one) && isNotBlank(another)) { } else if (isBlank(one) && isNotBlank(another)) {
return another; return another;
} }
throw new AssertionFailure("exactly one value required, but got '" + one + "' and '" + 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", () -> keep("partnerPersonUuid", () ->
httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerPersonTradeName}")) httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON), .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", () -> keep("BankAccount: Test AG - refund bank account", () ->
httpPost("/api/hs/office/bankaccounts", usingJsonBody(""" httpPost("/api/hs/office/bankaccounts", usingJsonBody("""

View File

@ -1,8 +1,8 @@
package net.hostsharing.hsadminng.hs.office.scenarios.partner; package net.hostsharing.hsadminng.hs.office.scenarios.partner;
import io.restassured.http.ContentType; 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.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON; import static io.restassured.http.ContentType.JSON;
@ -16,34 +16,35 @@ public class AddOperationsContactToPartner extends UseCase<AddOperationsContactT
@Override @Override
protected HttpResponse run() { protected HttpResponse run() {
keep("Person: %{operationsContactGivenName} %{operationsContactFamilyName}", () -> keep("Person: %{operationsContactGivenName} %{operationsContactFamilyName}",
httpPost("/api/hs/office/persons", usingJsonBody(""" () ->
{ httpPost("/api/hs/office/persons", usingJsonBody("""
"personType": "NATURAL_PERSON", {
"familyName": ${operationsContactFamilyName}, "personType": "NATURAL_PERSON",
"givenName": ${operationsContactGivenName} "familyName": ${operationsContactFamilyName},
} "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}", () -> keep("Contact: %{operationsContactGivenName} %{operationsContactFamilyName}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody(""" httpPost("/api/hs/office/contacts", usingJsonBody("""
{ {
"caption": "%{operationsContactGivenName} %{operationsContactFamilyName}", "caption": "%{operationsContactGivenName} %{operationsContactFamilyName}",
"phoneNumbers": { "phoneNumbers": {
"main": ${operationsContactPhoneNumber} "main": ${operationsContactPhoneNumber}
}, },
"emailAddresses": { "emailAddresses": {
"main": ${operationsContactEMailAddress} "main": ${operationsContactEMailAddress}
} }
} }
""")) """))
.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(""" return httpPost("/api/hs/office/relations", usingJsonBody("""
{ {

View File

@ -24,10 +24,10 @@ public class AddRepresentativeToPartner extends UseCase<AddRepresentativeToPartn
"givenName": ${representativeGivenName} "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}", () -> keep("Contact: %{representativeGivenName} %{representativeFamilyName}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody(""" 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(""" return httpPost("/api/hs/office/relations", usingJsonBody("""
{ {