From 87c7d2f531e03a79f4a368541ea89c4efeabb0dd Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 18 Dec 2024 10:49:05 +0100 Subject: [PATCH] feature/add-scenario-test-for-deceased-partner-with-community-of-heirs (#137) Co-authored-by: Michael Hoennig Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/137 Reviewed-by: Marc Sandlus --- .../config/JsonObjectMapperConfiguration.java | 5 + .../scenarios/HsOfficeScenarioTests.java | 55 +++++-- .../scenarios/partner/CreatePartner.java | 7 +- ...ceDeceasedPartnerWithCommunityOfHeirs.java | 139 ++++++++++++++++++ .../hsadminng/hs/scenarios/JsonOptional.java | 10 ++ .../hsadminng/hs/scenarios/ScenarioTest.java | 52 +++---- .../hsadminng/hs/scenarios/TestReport.java | 34 ++++- .../hsadminng/hs/scenarios/UseCase.java | 117 ++++++++++++--- 8 files changed, 347 insertions(+), 72 deletions(-) create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/ReplaceDeceasedPartnerWithCommunityOfHeirs.java diff --git a/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java b/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java index d5ff80d9..87276f34 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java +++ b/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java @@ -2,6 +2,7 @@ package net.hostsharing.hsadminng.config; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.openapitools.jackson.nullable.JsonNullableModule; @@ -14,6 +15,10 @@ import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; @Configuration public class JsonObjectMapperConfiguration { + public static ObjectMapper build() { + return new JsonObjectMapperConfiguration().customObjectMapper().build(); + } + @Bean @Primary public Jackson2ObjectMapperBuilder customObjectMapper() { diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java index d23a4250..f3037336 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java @@ -29,6 +29,7 @@ import net.hostsharing.hsadminng.hs.office.scenarios.partner.AddOperationsContac import net.hostsharing.hsadminng.hs.office.scenarios.partner.AddRepresentativeToPartner; import net.hostsharing.hsadminng.hs.office.scenarios.partner.CreatePartner; import net.hostsharing.hsadminng.hs.office.scenarios.partner.DeletePartner; +import net.hostsharing.hsadminng.hs.office.scenarios.partner.ReplaceDeceasedPartnerWithCommunityOfHeirs; import net.hostsharing.hsadminng.hs.office.scenarios.person.ShouldUpdatePersonData; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.RemoveOperationsContactFromPartner; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeExistingPersonAndContactToMailinglist; @@ -93,6 +94,8 @@ class HsOfficeScenarioTests extends ScenarioTest { """) .given("officePhoneNumber", "+49 40 654321-0") .given("emailAddress", "hamburg@test-ag.example.org") + .given("registrationOffice", "Registergericht Hamburg") + .given("registrationNumber", "1234567") .doRun() .keep(); } @@ -118,6 +121,9 @@ class HsOfficeScenarioTests extends ScenarioTest { """) .given("officePhoneNumber", "+49 40 123456") .given("emailAddress", "michelle.matthieu@example.org") + .given("birthday", "1951-03-25") + .given("birthPlace", "Neustadt a.d.R.") + .given("birthName", "Eichbaum") .doRun() .keep(); } @@ -221,16 +227,17 @@ class HsOfficeScenarioTests extends ScenarioTest { new ReplaceContactData(scenarioTest) .given("partnerName", "Test AG") .given("newContactCaption", "Test AG - China") - .given("newPostalAddress", """ - "firm": "Test AG", - "name": "Fi Zhong-Kha", - "building": "Thi Chi Koh Building", - "street": "No.2 Commercial Second Street", - "district": "Niushan Wei Wu", - "city": "Dongguan City", - "province": "Guangdong Province", - "country": "China" - """) + .given( + "newPostalAddress", """ + "firm": "Test AG", + "name": "Fi Zhong-Kha", + "building": "Thi Chi Koh Building", + "street": "No.2 Commercial Second Street", + "district": "Niushan Wei Wu", + "city": "Dongguan City", + "province": "Guangdong Province", + "country": "China" + """) .given("newOfficePhoneNumber", "++15 999 654321") .given("newEmailAddress", "norden@test-ag.example.org") .doRun(); @@ -257,6 +264,7 @@ class HsOfficeScenarioTests extends ScenarioTest { @Order(20) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class DebitorScenarios { + @Test @Order(2010) @Requires("Partner: P-31010 - Test AG") @@ -601,4 +609,31 @@ class HsOfficeScenarioTests extends ScenarioTest { .doRun(); } } + + @Nested + @Order(60) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class PartnerDeceasedScenarios { + + @Test + @Order(6010) + @Requires("Partner: P-31011 - Michelle Matthieu") + void shouldReplaceDeceasedPartnerByCommunityOfHeirs() { + new ReplaceDeceasedPartnerWithCommunityOfHeirs(scenarioTest) + .given("partnerNumber", "P-31011") + .given("dateOfDeath", "2024-11-15") + .given("representativeGivenName", "Lena") + .given("representativeFamilyName", "Stadland") + .given( + "communityOfHeirsPostalAddress", """ + "street": "Im Wischer 14", + "zipcode": "22987", + "city": "Hamburg", + "country": "Germany" + """) + .given("communityOfHeirsOfficePhoneNumber", "+49 40 666666") + .given("communityOfHeirsEmailAddress", "lena.stadland@example.org") + .doRun(); + } + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/CreatePartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/CreatePartner.java index 87ca7c87..a1ddccc4 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/CreatePartner.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/CreatePartner.java @@ -69,8 +69,11 @@ public class CreatePartner extends UseCase { "contact.uuid": ${Contact: %{contactCaption}} }, "details": { - "registrationOffice": "Registergericht Hamburg", - "registrationNumber": "1234567" + "birthday": ${birthday???}, + "birthPlace": ${birthPlace???}, + "birthName": ${birthName???}, + "registrationOffice": ${registrationOffice???}, + "registrationNumber": ${registrationNumber???} } } """)) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/ReplaceDeceasedPartnerWithCommunityOfHeirs.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/ReplaceDeceasedPartnerWithCommunityOfHeirs.java new file mode 100644 index 00000000..c706e8e6 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/ReplaceDeceasedPartnerWithCommunityOfHeirs.java @@ -0,0 +1,139 @@ +package net.hostsharing.hsadminng.hs.office.scenarios.partner; + +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; +import net.hostsharing.hsadminng.hs.scenarios.UseCase; +import org.springframework.http.HttpStatus; + +import static io.restassured.http.ContentType.JSON; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.OK; + +public class ReplaceDeceasedPartnerWithCommunityOfHeirs extends UseCase { + + public ReplaceDeceasedPartnerWithCommunityOfHeirs(final ScenarioTest testSuite) { + super(testSuite); + + } + + @Override + protected HttpResponse run() { + + obtain("Person: Hostsharing eG", () -> + httpGet("/api/hs/office/persons?name=Hostsharing+eG") + .expecting(OK).expecting(JSON), + response -> response.expectArrayElements(1).getFromBody("[0].uuid"), + "Even in production data we expect this query to return just a single result." // TODO.impl: add constraint? + ); + + obtain("Partner: %{partnerNumber}", () -> + httpGet("/api/hs/office/partners/%{partnerNumber}") + .reportWithResponse().expecting(OK).expecting(JSON), + response -> response.getFromBody("uuid"), + "Even in production data we expect this query to return just a single result." // TODO.impl: add constraint? + ) + .extractValue("partnerRel.holder.familyName", "familyNameOfDeceasedPerson") + .extractValue("partnerRel.holder.givenName", "givenNameOfDeceasedPerson") + .extractUuidAlias("partnerRel.holder.uuid", "Person: %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}"); + + obtain("Partner-Relation: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", () -> + httpPost("/api/hs/office/relations", usingJsonBody(""" + { + "type": "PARTNER", + "anchor.uuid": ${Person: Hostsharing eG}, + "holder": { + "personType": "UNINCORPORATED_FIRM", + "tradeName": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", + }, + "contact": { + "caption": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", + "postalAddress": { + "name": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", + "co": "%{representativeGivenName} %{representativeFamilyName}", + %{communityOfHeirsPostalAddress} + }, + "phoneNumbers": { + "office": ${communityOfHeirsOfficePhoneNumber} + }, + "emailAddresses": { + "main": ${communityOfHeirsEmailAddress} + } + } + } + """)) + .reportWithResponse().expecting(CREATED).expecting(JSON) + ) + .extractUuidAlias("contact.uuid", "Contact: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}") + .extractUuidAlias("holder.uuid", "Person: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}"); + + obtain("Representative-Relation: %{representativeGivenName} %{representativeFamilyName} for Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", () -> + httpPost("/api/hs/office/relations", usingJsonBody(""" + { + "type": "REPRESENTATIVE", + "anchor.uuid": ${Person: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}}, + "holder": { + "personType": "NATURAL_PERSON", + "givenName": ${representativeGivenName}, + "familyName": ${representativeFamilyName} + }, + "contact.uuid": ${Contact: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}} + } + """)) + .reportWithResponse().expecting(CREATED).expecting(JSON) + ).extractUuidAlias("holder.uuid", "Person: %{representativeGivenName} %{representativeFamilyName}"); + + obtain("Partner: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", () -> + httpPatch("/api/hs/office/partners/%{Partner: %{partnerNumber}}", usingJsonBody(""" + { + "partnerRel.uuid": ${Partner-Relation: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}} + } + """)) + .expecting(HttpStatus.OK) + ); + + // TODO.test: missing steps Debitor, Membership, Coop-Shares+Assets + + // Debitors + + // die Erbengemeinschaft wird als Anchor-Person (Partner) in die Debitor-Relations eingetragen + // der neue Rechnungsempfänger (z.B. auch ggf. Rechtsanwalt) wird als Holder-Person (Debitor-Person) in die Debitor-Relations eingetragen -- oder neu? + + // Membership + + // intro: die Mitgliedschaft geht juristisch gesehen auf die Erbengemeinschaft über + + // die bisherige Mitgliedschaft als DECEASED mit Ende-Datum=Todesdatum markieren + + // eine neue Mitgliedschaft (-00) mit dem Start-Datum=Todesdatum+1 anlegen + + // die Geschäftsanteile per share-tx: TRANSFER→ADOPT an die Erbengemeinschaft übertragen + // die Geschäftsguthaben per asset-tx: TRANSFER→ADOPT an die Erbengemeinschaft übertragen + + // outro: die Erbengemeinschaft hat eine Frist von 6 Monaten, um die Mitgliedschaft einer Person zu übertragen + // →nächster "Drecksfall" + + return null; + } + + @Override + protected void verify(final UseCase.HttpResponse response) { + verify( + "Verify the Updated Partner", + () -> httpGet("/api/hs/office/partners/%{partnerNumber}") + .expecting(OK).expecting(JSON).expectObject(), + path("partnerRel.holder.tradeName").contains("Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}") + ); + + // TODO.test: Verify the EX_PARTNER-Relation, once we fixed the anchor problem, see HsOfficePartnerController + // (net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerController.optionallyCreateExPartnerRelation) + + verify( + "Verify the Representative-Relation", + () -> httpGet("/api/hs/office/relations?relationType=REPRESENTATIVE&personUuid=%{Person: %{representativeGivenName} %{representativeFamilyName}}") + .expecting(OK).expecting(JSON).expectArrayElements(1), + path("[0].anchor.tradeName").contains("Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}"), + path("[0].holder.familyName").contains("%{representativeFamilyName}") + ); + + // TODO.test: Verify Debitor, Membership, Coop-Shares and Coop-Assets once implemented + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/JsonOptional.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/JsonOptional.java index f5c829c6..6fff42df 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/JsonOptional.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/JsonOptional.java @@ -1,5 +1,7 @@ package net.hostsharing.hsadminng.hs.scenarios; +import java.util.Objects; +import java.util.UUID; public final class JsonOptional { @@ -41,4 +43,12 @@ public final class JsonOptional { } return jsonValue == null ? null : jsonValue.toString(); } + + public UUID givenUUID() { + try { + return UUID.fromString(Objects.toString(jsonValue)); + } catch(final IllegalArgumentException e) { + throw new ClassCastException("expected a UUID, but got '" + jsonValue + "'"); + } + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java index 75ea20e7..9d65fed2 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java @@ -21,7 +21,6 @@ import java.math.BigDecimal; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Stack; import java.util.UUID; @@ -50,16 +49,7 @@ public abstract class ScenarioTest extends ContextBasedTest { return Optional.of(currentTestMethodProduces.pop()); } - record Alias>(Class useCase, UUID uuid) { - - @Override - public String toString() { - return Objects.toString(uuid); - } - - } - - private final static Map> aliases = new HashMap<>(); + private final static Map aliases = new HashMap<>(); private final static Map properties = new HashMap<>(); public final TestReport testReport = new TestReport(aliases); @@ -90,18 +80,7 @@ public abstract class ScenarioTest extends ContextBasedTest { @AfterEach void afterScenario(final TestInfo testInfo) { // final TestInfo testInfo - testInfo.getTestMethod() .ifPresent(currentTestMethod -> { - // FIXME: extract to method - final var producesAnnot = currentTestMethod.getAnnotation(Produces.class); - if (producesAnnot != null && producesAnnot.permanent()) { - final var testMethodProduces = producedAliases(producesAnnot); - testMethodProduces.forEach(declaredAlias -> - assertThat(knowVariables().containsKey(declaredAlias)) - .as("@Producer method " + currentTestMethod.getName() + - " did declare but not produce \"" + declaredAlias + "\"") - .isTrue() ); - } - }); + verifyProduceDeclaration(testInfo); properties.clear(); testReport.close(); @@ -111,14 +90,11 @@ public abstract class ScenarioTest extends ContextBasedTest { jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); - aliases.put( + putAlias( "Person: Hostsharing eG", - new Alias<>( - null, - personRepo.findPersonByOptionalNameLike("Hostsharing eG") - .stream() + personRepo.findPersonByOptionalNameLike("Hostsharing eG").stream() .map(HsOfficePersonRbacEntity::getUuid) - .reduce(Reducer::toSingleElement).orElseThrow()) + .reduce(Reducer::toSingleElement).orElseThrow() ); } ); @@ -195,6 +171,20 @@ public abstract class ScenarioTest extends ContextBasedTest { } } + private static void verifyProduceDeclaration(final TestInfo testInfo) { + testInfo.getTestMethod().ifPresent(currentTestMethod -> { + final var producesAnnot = currentTestMethod.getAnnotation(Produces.class); + if (producesAnnot != null && producesAnnot.permanent()) { + final var testMethodProduces = producedAliases(producesAnnot); + testMethodProduces.forEach(declaredAlias -> + assertThat(knowVariables().containsKey(declaredAlias)) + .as("@Producer method " + currentTestMethod.getName() + + " did declare but not produce \"" + declaredAlias + "\"") + .isTrue() ); + } + }); + } + static boolean containsAlias(final String alias) { return aliases.containsKey(alias); } @@ -210,7 +200,7 @@ public abstract class ScenarioTest extends ContextBasedTest { return alias; } - static void putAlias(final String name, final Alias value) { + static void putAlias(final String name, final UUID value) { aliases.put(name, value); } @@ -224,7 +214,7 @@ public abstract class ScenarioTest extends ContextBasedTest { static Map knowVariables() { final var map = new LinkedHashMap(); - ScenarioTest.aliases.forEach((key, value) -> map.put(key, value.uuid())); + map.putAll(ScenarioTest.aliases); map.putAll(ScenarioTest.properties); return map; } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TestReport.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TestReport.java index b700d556..028e679a 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TestReport.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/TestReport.java @@ -1,6 +1,9 @@ package net.hostsharing.hsadminng.hs.scenarios; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.SneakyThrows; +import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; import net.hostsharing.hsadminng.system.SystemProcess; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Order; @@ -15,6 +18,7 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.Map; +import java.util.UUID; import java.util.regex.Pattern; import static java.lang.String.join; @@ -23,10 +27,12 @@ import static org.assertj.core.api.Assertions.assertThat; public class TestReport { public static final File BUILD_DOC_SCENARIOS = new File("build/doc/scenarios"); - private final static File markdownLogFile = new File(BUILD_DOC_SCENARIOS, ".last-debug-log.md"); public static final SimpleDateFormat MM_DD_YYYY_HH_MM_SS = new SimpleDateFormat("MM-dd-yyyy hh:mm:ss"); - private final Map aliases; + private static final File markdownLogFile = new File(BUILD_DOC_SCENARIOS, ".last-debug-log.md"); + private static final ObjectMapper objectMapper = JsonObjectMapperConfiguration.build(); + + private final Map aliases; private final PrintWriter markdownLog; // records everything for debugging purposes private File markdownReportFile; private PrintWriter markdownReport; // records only the use-case under test, without its pre-requisites @@ -38,7 +44,7 @@ public class TestReport { } @SneakyThrows - public TestReport(final Map aliases) { + public TestReport(final Map aliases) { this.aliases = aliases; this.markdownLog = new PrintWriter(new FileWriter(markdownLogFile)); } @@ -76,6 +82,11 @@ public class TestReport { printLine("\n" +output + "\n"); } + @SneakyThrows + public void printJson(final String json) { + printLine(prettyJson(json)); + } + void silent(final Runnable code) { silent++; code.run(); @@ -100,6 +111,14 @@ public class TestReport { return convertedTestMethodName.replaceAll(": should ", ": "); } + private static String prettyJson(final String json) throws JsonProcessingException { + if (json == null) { + return ""; + } + final var jsonNode = objectMapper.readTree(json); + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); + } + private String asClickableLink(final File file) { return file.toURI().toString().replace("file:/", "file:///"); } @@ -113,9 +132,8 @@ public class TestReport { final var result = new StringBuilder(); for (String line : lines) { - for (Map.Entry entry : aliases.entrySet()) { - final var uuidString = entry.getValue().toString(); - if (line.contains(uuidString)) { + for (Map.Entry entry : aliases.entrySet()) { + if ( entry.getValue() != null && line.contains(entry.getValue().toString())) { line = line + " // " + entry.getKey(); break; // only add comment for one UUID per row (in our case, there is only one per row) } @@ -151,4 +169,8 @@ public class TestReport { return "unknown"; } } + + public boolean isSilent() { + return silent > 0; + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java index 7b438134..01c5dede 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java @@ -122,29 +122,33 @@ public abstract class UseCase> { return new JsonTemplate(jsonTemplate); } - public final void obtain( + public final HttpResponse obtain( final String title, final Supplier http, final Function extractor, final String... extraInfo) { - withTitle(title, () -> { + return withTitle(title, () -> { final var response = http.get().keep(extractor); + response.optionallyReportRequestAndResponse(); Arrays.stream(extraInfo).forEach(testReport::printPara); return response; }); } - public final void obtain(final String alias, final Supplier http, final String... extraInfo) { - withTitle(alias, () -> { - final var response = http.get().keep(); + public final HttpResponse obtain(final String alias, final Supplier 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 code) { + public HttpResponse withTitle(final String resolvableTitle, final Supplier httpCall, final String... extraInfo) { this.nextTitle = resolvableTitle; - final var response = code.get(); + final var response = httpCall.get(); + response.optionallyReportRequestAndResponse(); + Arrays.stream(extraInfo).forEach(testReport::printPara); this.nextTitle = null; return response; } @@ -261,6 +265,10 @@ public abstract class UseCase> { public final class HttpResponse { + private final HttpMethod httpMethod; + private final String uri; + private final String requestBody; + @Getter private final java.net.http.HttpResponse response; @@ -270,6 +278,9 @@ public abstract class UseCase> { @Getter private UUID locationUuid; + private boolean reportGenerated = false; + private boolean reportGeneratedWithResponse = false; + @SneakyThrows public HttpResponse( final HttpMethod httpMethod, @@ -277,6 +288,9 @@ public abstract class UseCase> { final String requestBody, final java.net.http.HttpResponse response ) { + this.httpMethod = httpMethod; + this.uri = uri; + this.requestBody = requestBody; this.response = response; this.status = HttpStatus.valueOf(response.statusCode()); if (this.status == HttpStatus.CREATED) { @@ -284,40 +298,42 @@ public abstract class UseCase> { assertThat(location).startsWith("http://localhost:"); locationUuid = UUID.fromString(location.substring(location.lastIndexOf('/') + 1)); } - - reportRequestAndResponse(httpMethod, uri, requestBody); } 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 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, - new ScenarioTest.Alias<>(UseCase.this.getClass(), UUID.fromString(value))); + ScenarioTest.putAlias(alias, UUID.fromString(value)); return this; } public HttpResponse keepAs(final String alias) { - ScenarioTest.putAlias( - nonNullAlias(alias), - new ScenarioTest.Alias<>(UseCase.this.getClass(), locationUuid)); + 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(); @@ -326,6 +342,8 @@ public abstract class UseCase> { @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(); @@ -335,6 +353,15 @@ public abstract class UseCase> { 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 getFromBody(final String path) { final var body = response.body(); @@ -357,14 +384,23 @@ public abstract class UseCase> { return assertThat(getFromBodyAsOptional(path).givenAsString()); } + public HttpResponse reportWithResponse() { + return reportRequestAndResponse(true); + } + @SneakyThrows - private void reportRequestAndResponse(final HttpMethod httpMethod, final String uri, final String requestBody) { + private HttpResponse reportRequestAndResponse(final boolean unconditionallyWithResponse) { + if (reportGenerated) { + throw new IllegalStateException("request report already generated"); + } // the title if (nextTitle != null) { - testReport.printLine("\n### " + ScenarioTest.resolve(nextTitle, KEEP_COMMENTS) + "\n"); + testReport.printPara("### " + ScenarioTest.resolve(nextTitle, KEEP_COMMENTS)); } else if (resultAlias != null) { - testReport.printLine("\n### Create " + resultAlias + "\n"); + testReport.printPara("### Create " + resultAlias); + } else if (testReport.isSilent()) { + testReport.printPara("### Untitled Section"); } else { fail("please wrap the http...-call in the UseCase using `withTitle(...)`"); } @@ -372,17 +408,34 @@ public abstract class UseCase> { // the request testReport.printLine("```"); testReport.printLine(httpMethod.name() + " " + uri); - testReport.printLine((requestBody != null ? requestBody.trim() : "")); + testReport.printJson(requestBody); // the response testReport.printLine("=> status: " + status + " " + (locationUuid != null ? locationUuid : "")); - if (httpMethod == HttpMethod.GET || status.isError()) { - final var jsonNode = objectMapper.readTree(response.body()); - final var prettyJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); - testReport.printLine(prettyJson); + 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) { @@ -391,6 +444,24 @@ public abstract class UseCase> { 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() {