From 5b0c8f477c8424954569b2c7e85d911149760027 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 26 Nov 2024 19:12:49 +0100 Subject: [PATCH] implement coo-asset-transfer-transaction-reversal --- ...OfficeCoopAssetsTransactionController.java | 125 +++++++++++++----- .../HsOfficeCoopAssetsTransactionEntity.java | 8 +- .../hsadminng/lambda/WithNonNull.java | 11 ++ .../5120-hs-office-coopassets.sql | 44 ++++-- .../scenarios/HsOfficeScenarioTests.java | 21 +-- .../hs/office/scenarios/JsonOptional.java | 44 ++++++ .../hs/office/scenarios/PathAssertion.java | 13 +- .../hs/office/scenarios/ScenarioTest.java | 19 ++- .../hs/office/scenarios/TestReport.java | 24 +++- .../hs/office/scenarios/UseCase.java | 43 ++++-- ...ateCoopAssetsRevertSimpleTransaction.java} | 8 +- ...teCoopAssetsRevertTransferTransaction.java | 59 +++++++++ .../CreateCoopAssetsTransferTransaction.java | 5 +- .../hsadminng/lambda/WithNonNullUnitTest.java | 30 +++++ 14 files changed, 362 insertions(+), 92 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/lambda/WithNonNull.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/JsonOptional.java rename src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/{CreateCoopAssetsRevertTransaction.java => CreateCoopAssetsRevertSimpleTransaction.java} (71%) create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertTransferTransaction.java create mode 100644 src/test/java/net/hostsharing/hsadminng/lambda/WithNonNullUnitTest.java diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java index 24a1919c..4e2c6498 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java @@ -27,11 +27,13 @@ import java.util.UUID; import java.util.function.BiConsumer; import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionType.REVERSAL; +import static net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionType.TRANSFER; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.CLEARING; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.DEPOSIT; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.DISBURSAL; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.LOSS; -import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.TRANSFER; +import static net.hostsharing.hsadminng.lambda.WithNonNull.withNonNull; @RestController public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAssetsApi { @@ -66,7 +68,10 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse fromValueDate, toValueDate); - final var resources = mapper.mapList(entities, HsOfficeCoopAssetsTransactionResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); + final var resources = mapper.mapList( + entities, + HsOfficeCoopAssetsTransactionResource.class, + ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(resources); } @@ -106,7 +111,11 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse if (result.isEmpty()) { return ResponseEntity.notFound().build(); } - return ResponseEntity.ok(mapper.map(result.get(), HsOfficeCoopAssetsTransactionResource.class)); + final var resource = mapper.map( + result.get(), + HsOfficeCoopAssetsTransactionResource.class, + ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(resource); } @@ -131,7 +140,8 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse private static void validateCreditTransaction( final HsOfficeCoopAssetsTransactionInsertResource requestBody, final ArrayList violations) { - if (List.of(DISBURSAL, TRANSFER, CLEARING, LOSS).contains(requestBody.getTransactionType()) + if (List.of(DISBURSAL, HsOfficeCoopAssetsTransactionTypeResource.TRANSFER, CLEARING, LOSS) + .contains(requestBody.getTransactionType()) && requestBody.getAssetValue().signum() > 0) { violations.add("for %s, assetValue must be negative but is \"%.2f\"".formatted( requestBody.getTransactionType(), requestBody.getAssetValue())); @@ -151,53 +161,102 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse resource.setMembershipUuid(entity.getMembership().getUuid()); resource.setMembershipMemberNumber(entity.getMembership().getTaggedMemberNumber()); - if (entity.getReversalAssetTx() != null) { - resource.getReversalAssetTx().setRevertedAssetTxUuid(entity.getUuid()); - resource.getReversalAssetTx().setMembershipUuid(entity.getMembership().getUuid()); - resource.getReversalAssetTx().setMembershipMemberNumber(entity.getTaggedMemberNumber()); - } + withNonNull( + resource.getReversalAssetTx(), reversalAssetTxResource -> { + reversalAssetTxResource.setMembershipUuid(entity.getMembership().getUuid()); + reversalAssetTxResource.setMembershipMemberNumber(entity.getTaggedMemberNumber()); + reversalAssetTxResource.setRevertedAssetTxUuid(entity.getUuid()); + withNonNull( + entity.getAdoptionAssetTx(), adoptionAssetTx -> + reversalAssetTxResource.setAdoptionAssetTxUuid(adoptionAssetTx.getUuid())); + withNonNull( + entity.getTransferAssetTx(), transferAssetTxResource -> + reversalAssetTxResource.setTransferAssetTxUuid(transferAssetTxResource.getUuid())); + }); - if (entity.getRevertedAssetTx() != null) { - resource.getRevertedAssetTx().setReversalAssetTxUuid(entity.getUuid()); - resource.getRevertedAssetTx().setMembershipUuid(entity.getMembership().getUuid()); - resource.getRevertedAssetTx().setMembershipMemberNumber(entity.getTaggedMemberNumber()); - } + withNonNull( + resource.getRevertedAssetTx(), revertAssetTxResource -> { + revertAssetTxResource.setMembershipUuid(entity.getMembership().getUuid()); + revertAssetTxResource.setMembershipMemberNumber(entity.getTaggedMemberNumber()); + revertAssetTxResource.setReversalAssetTxUuid(entity.getUuid()); + withNonNull( + entity.getRevertedAssetTx().getAdoptionAssetTx(), adoptionAssetTx -> + revertAssetTxResource.setAdoptionAssetTxUuid(adoptionAssetTx.getUuid())); + withNonNull( + entity.getRevertedAssetTx().getTransferAssetTx(), transferAssetTxResource -> + revertAssetTxResource.setTransferAssetTxUuid(transferAssetTxResource.getUuid())); + }); - if (entity.getAdoptionAssetTx() != null) { - resource.getAdoptionAssetTx().setTransferAssetTxUuid(entity.getUuid()); - resource.getAdoptionAssetTx().setMembershipUuid(entity.getAdoptionAssetTx().getMembership().getUuid()); - resource.getAdoptionAssetTx().setMembershipMemberNumber(entity.getAdoptionAssetTx().getTaggedMemberNumber()); - } + withNonNull( + resource.getAdoptionAssetTx(), adoptionAssetTxResource -> { + adoptionAssetTxResource.setMembershipUuid(entity.getAdoptionAssetTx().getMembership().getUuid()); + adoptionAssetTxResource.setMembershipMemberNumber(entity.getAdoptionAssetTx().getTaggedMemberNumber()); + adoptionAssetTxResource.setTransferAssetTxUuid(entity.getUuid()); + withNonNull( + entity.getAdoptionAssetTx().getReversalAssetTx(), reversalAssetTx -> + adoptionAssetTxResource.setReversalAssetTxUuid(reversalAssetTx.getUuid())); + }); - if (entity.getTransferAssetTx() != null) { - resource.getTransferAssetTx().setAdoptionAssetTxUuid(entity.getUuid()); - resource.getTransferAssetTx().setMembershipUuid(entity.getTransferAssetTx().getMembership().getUuid()); - resource.getTransferAssetTx().setMembershipMemberNumber(entity.getTransferAssetTx().getTaggedMemberNumber()); - } + withNonNull( + resource.getTransferAssetTx(), transferAssetTxResource -> { + resource.getTransferAssetTx().setMembershipUuid(entity.getTransferAssetTx().getMembership().getUuid()); + resource.getTransferAssetTx() + .setMembershipMemberNumber(entity.getTransferAssetTx().getTaggedMemberNumber()); + resource.getTransferAssetTx().setAdoptionAssetTxUuid(entity.getUuid()); + withNonNull( + entity.getTransferAssetTx().getReversalAssetTx(), reversalAssetTx -> + transferAssetTxResource.setReversalAssetTxUuid(reversalAssetTx.getUuid())); + }); }; final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { if (resource.getMembershipUuid() != null) { - final HsOfficeMembershipEntity membership = ofNullable(emw.find(HsOfficeMembershipEntity.class, resource.getMembershipUuid())) + final HsOfficeMembershipEntity membership = ofNullable(emw.find( + HsOfficeMembershipEntity.class, + resource.getMembershipUuid())) .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] membership.uuid %s not found".formatted( resource.getMembershipUuid()))); entity.setMembership(membership); } - if (resource.getRevertedAssetTxUuid() != null) { + + if (entity.getTransactionType() == REVERSAL) { + if (resource.getRevertedAssetTxUuid() == null) { + throw new ValidationException("REVERSAL asset transaction requires revertedAssetTx.uuid"); + } final var revertedAssetTx = coopAssetsTransactionRepo.findByUuid(resource.getRevertedAssetTxUuid()) .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] revertedEntityUuid %s not found".formatted( resource.getRevertedAssetTxUuid()))); + revertedAssetTx.setReversalAssetTx(entity); entity.setRevertedAssetTx(revertedAssetTx); if (resource.getAssetValue().negate().compareTo(revertedAssetTx.getAssetValue()) != 0) { throw new ValidationException("given assetValue=" + resource.getAssetValue() + " but must be negative value from reverted asset tx: " + revertedAssetTx.getAssetValue()); } + + if (revertedAssetTx.getTransactionType() == TRANSFER) { + final var adoptionAssetTx = revertedAssetTx.getAdoptionAssetTx(); + final var adoptionReversalAssetTx = HsOfficeCoopAssetsTransactionEntity.builder() + .transactionType(REVERSAL) + .membership(adoptionAssetTx.getMembership()) // FIXME: check + .revertedAssetTx(adoptionAssetTx) + .assetValue(adoptionAssetTx.getAssetValue().negate()) + .comment(resource.getComment()) + .reference(resource.getReference()) + .valueDate(resource.getValueDate()) + .build(); + adoptionAssetTx.setReversalAssetTx(adoptionReversalAssetTx); + adoptionReversalAssetTx.setRevertedAssetTx(adoptionAssetTx); + } } - final var adoptingMembership = determineAdoptingMembership(resource); - if (adoptingMembership != null) { - final var adoptingAssetTx = coopAssetsTransactionRepo.save(createAdoptingAssetTx(entity, adoptingMembership)); + if (resource.getTransactionType() == HsOfficeCoopAssetsTransactionTypeResource.TRANSFER) { + final var adoptingMembership = determineAdoptingMembership(resource); + if (adoptingMembership == null) { + throw new ValidationException( + "TRANSFER asset transaction requires adoptingMembership.uuid or adoptingMembership.memberNumber"); + } + final var adoptingAssetTx = createAdoptingAssetTx(entity, adoptingMembership); entity.setAdoptionAssetTx(adoptingAssetTx); } }; @@ -207,8 +266,8 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse final var adoptingMembershipMemberNumber = resource.getAdoptingMembershipMemberNumber(); if (adoptingMembershipUuid != null && adoptingMembershipMemberNumber != null) { throw new IllegalArgumentException( - // @formatter:off - resource.getTransactionType() == TRANSFER + // @formatter:off + resource.getTransactionType() == HsOfficeCoopAssetsTransactionTypeResource.TRANSFER ? "[400] either adoptingMembership.uuid or adoptingMembership.memberNumber can be given, not both" : "[400] adoptingMembership.uuid and adoptingMembership.memberNumber must not be given for transactionType=" + resource.getTransactionType()); @@ -232,10 +291,10 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse + "' not found or not accessible"); } - if (resource.getTransactionType() == TRANSFER) { + if (resource.getTransactionType() == HsOfficeCoopAssetsTransactionTypeResource.TRANSFER) { throw new ValidationException( "either adoptingMembership.uuid or adoptingMembership.memberNumber must be given for transactionType=" - + TRANSFER); + + HsOfficeCoopAssetsTransactionTypeResource.TRANSFER); } return null; diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java index 395c2895..b07bdebe 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java @@ -98,21 +98,21 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, BaseE private String comment; // Optionally, the UUID of the corresponding transaction for a reversal transaction. - @OneToOne(cascade = CascadeType.PERSIST) // TODO.impl: can probably be removed after office data migration + @OneToOne @JoinColumn(name = "revertedassettxuuid") private HsOfficeCoopAssetsTransactionEntity revertedAssetTx; // and the other way around - @OneToOne(mappedBy = "revertedAssetTx") + @OneToOne(mappedBy = "revertedAssetTx", cascade = CascadeType.PERSIST) private HsOfficeCoopAssetsTransactionEntity reversalAssetTx; // Optionally, the UUID of the corresponding transaction for a transfer transaction. - @OneToOne(cascade = CascadeType.PERSIST) // TODO.impl: can probably be removed after office data migration + @OneToOne(cascade = CascadeType.PERSIST) @JoinColumn(name = "assetadoptiontxuuid") private HsOfficeCoopAssetsTransactionEntity adoptionAssetTx; // and the other way around - @OneToOne(mappedBy = "adoptionAssetTx") + @OneToOne(mappedBy = "adoptionAssetTx", cascade = CascadeType.PERSIST) private HsOfficeCoopAssetsTransactionEntity transferAssetTx; @Override diff --git a/src/main/java/net/hostsharing/hsadminng/lambda/WithNonNull.java b/src/main/java/net/hostsharing/hsadminng/lambda/WithNonNull.java new file mode 100644 index 00000000..358f5319 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/lambda/WithNonNull.java @@ -0,0 +1,11 @@ +package net.hostsharing.hsadminng.lambda; + +import java.util.function.Consumer; + +public class WithNonNull { + public static void withNonNull(final T target, final Consumer code) { + if (target != null ) { + code.accept(target); + } + } +} diff --git a/src/main/resources/db/changelog/5-hs-office/512-coopassets/5120-hs-office-coopassets.sql b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5120-hs-office-coopassets.sql index 37c2affc..524e620c 100644 --- a/src/main/resources/db/changelog/5-hs-office/512-coopassets/5120-hs-office-coopassets.sql +++ b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5120-hs-office-coopassets.sql @@ -35,21 +35,41 @@ create table if not exists hs_office.coopassettx --changeset michael.hoennig:hs-office-coopassets-BUSINESS-RULES endDelimiter:--// -- ---------------------------------------------------------------------------- -alter table hs_office.coopassettx - add constraint reversal_asset_tx_must_have_reverted_asset_tx - check (transactionType <> 'REVERSAL' or revertedAssetTxUuid is not null); +-- Not as CHECK constraints because those cannot be deferrable, +-- but we need these constraints deferrable because the rows are linked to each other. -alter table hs_office.coopassettx - add constraint non_reversal_asset_tx_must_not_have_reverted_asset_tx - check (transactionType = 'REVERSAL' or revertedAssetTxUuid is null or transactionType = 'REVERSAL'); +CREATE OR REPLACE FUNCTION validate_transaction_type() + RETURNS TRIGGER AS $$ +BEGIN + -- REVERSAL transactions must have revertedAssetTxUuid + IF NEW.transactionType = 'REVERSAL' AND NEW.revertedAssetTxUuid IS NULL THEN + RAISE EXCEPTION 'REVERSAL transactions must have revertedAssetTxUuid'; + END IF; -alter table hs_office.coopassettx - add constraint transfer_asset_tx_must_have_adopted_asset_tx - check (transactionType <> 'TRANSFER' or assetAdoptionTxUuid is not null); + -- Non-REVERSAL transactions must not have revertedAssetTxUuid + IF NEW.transactionType != 'REVERSAL' AND NEW.revertedAssetTxUuid IS NOT NULL THEN + RAISE EXCEPTION 'Non-REVERSAL transactions must not have revertedAssetTxUuid'; + END IF; + + -- TRANSFER transactions must have assetAdoptionTxUuid + IF NEW.transactionType = 'TRANSFER' AND NEW.assetAdoptionTxUuid IS NULL THEN + RAISE EXCEPTION 'TRANSFER transactions must have assetAdoptionTxUuid'; + END IF; + + -- Non-TRANSFER transactions must not have assetAdoptionTxUuid + IF NEW.transactionType != 'TRANSFER' AND NEW.assetAdoptionTxUuid IS NOT NULL THEN + RAISE EXCEPTION 'Non-TRANSFER transactions must not have assetAdoptionTxUuid'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Attach the trigger to the table +CREATE TRIGGER enforce_transaction_constraints + AFTER INSERT OR UPDATE ON hs_office.coopassettx + FOR EACH ROW EXECUTE FUNCTION validate_transaction_type(); -alter table hs_office.coopassettx - add constraint non_transfer_asset_tx_must_not_have_adopted_asset_tx - check (transactionType = 'TRANSFER' or assetAdoptionTxUuid is null); --// -- ============================================================================ 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 dec419f0..eecd0a4f 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 @@ -16,7 +16,8 @@ import net.hostsharing.hsadminng.hs.office.scenarios.membership.CancelMembership import net.hostsharing.hsadminng.hs.office.scenarios.membership.CreateMembership; import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets.CreateCoopAssetsDepositTransaction; import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets.CreateCoopAssetsDisbursalTransaction; -import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets.CreateCoopAssetsRevertTransaction; +import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets.CreateCoopAssetsRevertSimpleTransaction; +import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets.CreateCoopAssetsRevertTransferTransaction; import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets.CreateCoopAssetsTransferTransaction; import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopshares.CreateCoopSharesCancellationTransaction; import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopshares.CreateCoopSharesRevertTransaction; @@ -30,7 +31,6 @@ import net.hostsharing.hsadminng.hs.office.scenarios.subscription.RemoveOperatio import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeToMailinglist; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; -import net.hostsharing.hsadminng.test.IgnoreOnFailure; import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.MethodOrderer; @@ -395,7 +395,7 @@ class HsOfficeScenarioTests extends ScenarioTest { @Order(4302) @Requires("Membership: M-3101000 - Test AG") void shouldRevertCoopAssetsSubscription() { - new CreateCoopAssetsRevertTransaction(this) + new CreateCoopAssetsRevertSimpleTransaction(this) .given("memberNumber", "M-3101000") .given("comment", "reverting some incorrect transaction") .given("dateOfIncorrectTransaction", "2024-02-15") @@ -419,13 +419,13 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(4304) @Requires("Coop-Assets M-3101000 - Test AG - DEPOSIT Transaction") - @Produces("Coop-Assets M-3101000 - Test AG - TRANSFER Transaction") + @Produces(explicitly = "Coop-Assets M-3101000 - Test AG - TRANSFER Transaction", implicitly = "Membership M-4303000") void shouldTransferCoopAssets() { new CreateCoopAssetsTransferTransaction(this) .given("transferringMemberNumber", "M-3101000") .given("adoptingMemberNumber", "M-4303000") .given("reference", "transfer 2024-12-31") - .given("valueToDisburse", 2 * 64) + .given("valueToTransfer", 2 * 64) .given("comment", "transfer assets from M-3101000 to M-4303000") .given("transactionDate", "2024-12-31") .doRun(); @@ -433,11 +433,12 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(4305) - @Requires("Coop-Assets M-3101000 - Test AG - TRANSFER Transaction") - @IgnoreOnFailure("TODO.impl: reverting transfers is not implemented yet") - void shouldRevertCoopAssetsTransfer() { - new CreateCoopAssetsRevertTransaction(this) - .given("memberNumber", "M-3101000") + @Requires("Membership M-4303000") + void shouldRevertCoopAssetsTransferIncludingRelatedAssetAdoption() { + new CreateCoopAssetsRevertTransferTransaction(this) + .given("transferringMemberNumber", "M-3101000") + .given("adoptingMemberNumber", "M-4303000") + .given("transferredValue", 2*64) .given("comment", "reverting some incorrect transfer transaction") .given("dateOfIncorrectTransaction", "2024-02-15") .doRun(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/JsonOptional.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/JsonOptional.java new file mode 100644 index 00000000..2a169fe2 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/JsonOptional.java @@ -0,0 +1,44 @@ +package net.hostsharing.hsadminng.hs.office.scenarios; + + +public final class JsonOptional { + + private final boolean jsonValueGiven; + private final V jsonValue; + + private JsonOptional() { + this.jsonValueGiven = false; + this.jsonValue = null; + } + + private JsonOptional(final V jsonValue) { + this.jsonValueGiven = true; + this.jsonValue = jsonValue; + } + + public static JsonOptional ofValue(final T value) { + return new JsonOptional<>(value); + } + + public static JsonOptional notGiven() { + return new JsonOptional<>(); + } + + public V given() { + if (!jsonValueGiven) { + throw new IllegalStateException("JSON value was not given"); + } + return jsonValue; + } + + public String givenAsString() { + if (jsonValue instanceof Double doubleValue) { + if (doubleValue % 1 == 0) { + return String.valueOf(doubleValue.intValue()); // avoid trailing ".0" + } else { + return doubleValue.toString(); + } + } + return jsonValue == null ? null : jsonValue.toString(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/PathAssertion.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/PathAssertion.java index 6d3dc2a2..e7aac280 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/PathAssertion.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/PathAssertion.java @@ -19,7 +19,7 @@ public class PathAssertion { public Consumer contains(final String resolvableValue) { return response -> { try { - response.path(path).map(this::asString).contains(ScenarioTest.resolve(resolvableValue, DROP_COMMENTS)); + response.path(path).isEqualTo(ScenarioTest.resolve(resolvableValue, DROP_COMMENTS)); } catch (final AssertionError e) { // without this, the error message is often lacking important context fail(e.getMessage() + " in `path(\"" + path + "\").contains(\"" + resolvableValue + "\")`" ); @@ -37,15 +37,4 @@ public class PathAssertion { } }; } - - private String asString(final Object value) { - if (value instanceof Double doubleValue) { - if (doubleValue % 1 == 0) { - return String.valueOf(doubleValue.intValue()); // avoid trailing ".0" - } else { - return doubleValue.toString(); - } - } - return value.toString(); - } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/ScenarioTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/ScenarioTest.java index 3e0dab34..4e3557eb 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/ScenarioTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/ScenarioTest.java @@ -16,6 +16,7 @@ import org.springframework.boot.test.web.server.LocalServerPort; import org.testcontainers.shaded.org.apache.commons.lang3.ObjectUtils; import java.lang.reflect.Method; +import java.math.BigDecimal; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -160,6 +161,10 @@ public abstract class ScenarioTest extends ContextBasedTest { properties.put(name, (value instanceof String string) ? resolveTyped(string) : value); } + static void removeProperty(final String propName) { + properties.remove(propName); + } + static Map knowVariables() { final var map = new LinkedHashMap(); ScenarioTest.aliases.forEach((key, value) -> map.put(key, value.uuid())); @@ -172,8 +177,8 @@ public abstract class ScenarioTest extends ContextBasedTest { return resolved; } - public static Object resolveTyped(final String text) { - final var resolved = resolve(text, DROP_COMMENTS); + public static Object resolveTyped(final String resolvableText) { + final var resolved = resolve(resolvableText, DROP_COMMENTS); try { return UUID.fromString(resolved); } catch (final IllegalArgumentException e) { @@ -182,4 +187,14 @@ public abstract class ScenarioTest extends ContextBasedTest { return resolved; } + public static T resolveTyped(final String resolvableText, final Class valueType) { + final var resolvedValue = resolve(resolvableText, DROP_COMMENTS); + if (valueType == BigDecimal.class) { + //noinspection unchecked + return (T) new BigDecimal(resolvedValue); + } + //noinspection unchecked + return (T) resolvedValue; + } + } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TestReport.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TestReport.java index ec6e0fd6..f0b47bcc 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TestReport.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TestReport.java @@ -12,9 +12,12 @@ import java.io.IOException; import java.io.PrintWriter; import java.lang.reflect.Method; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; import java.util.Map; +import java.util.regex.Pattern; +import static java.lang.String.join; import static org.assertj.core.api.Assertions.assertThat; public class TestReport { @@ -41,9 +44,12 @@ public class TestReport { } public void createTestLogMarkdownFile(final TestInfo testInfo) throws IOException { - final var testMethodName = testInfo.getTestMethod().map(Method::getName).orElseThrow(); + final var testMethodName = testInfo.getTestMethod().map(Method::getName) + .map(TestReport::chopShouldPrefix) + .map(TestReport::splitMixedCaseIntoSeparateWords) + .orElseThrow(); final var testMethodOrder = testInfo.getTestMethod().map(m -> m.getAnnotation(Order.class).value()).orElseThrow(); - markdownReportFile = new File(BUILD_DOC_SCENARIOS, testMethodOrder + "-" + testMethodName + ".md"); + markdownReportFile = new File(BUILD_DOC_SCENARIOS, testMethodOrder + ": " + testMethodName + ".md"); markdownReport = new PrintWriter(new FileWriter(markdownReportFile)); print("## Scenario #" + determineScenarioTitle(testInfo)); } @@ -119,6 +125,20 @@ public class TestReport { return result.toString(); } + private static String chopShouldPrefix(final String text) { + return text.replaceAll("^should", ""); + } + + private static String splitMixedCaseIntoSeparateWords(final String text) { + final var WORD_FINDER = Pattern.compile("(([A-Z]?[a-z]+)|([A-Z]))"); + final var matcher = WORD_FINDER.matcher(text); + final var words = new ArrayList(); + while (matcher.find()) { + words.add(matcher.group(0)); + } + return join(" ", words); + } + @SneakyThrows private String currentGitBranch() { try { diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCase.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCase.java index 27e38e13..58d76759 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCase.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCase.java @@ -9,13 +9,14 @@ import lombok.Getter; import lombok.SneakyThrows; import net.hostsharing.hsadminng.reflection.AnnotationFinder; import org.apache.commons.collections4.map.LinkedMap; -import org.assertj.core.api.OptionalAssert; +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; @@ -27,13 +28,13 @@ import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; 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.office.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; import static net.hostsharing.hsadminng.hs.office.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS; import static net.hostsharing.hsadminng.test.DebuggerDetection.isDebuggerAttached; @@ -95,6 +96,9 @@ public abstract class UseCase> { ); final var response = run(); verify(response); + + resetProperties(); + return response; } @@ -109,7 +113,7 @@ public abstract class UseCase> { } public final UseCase given(final String propName, final Object propValue) { - givenProperties.put(propName, propValue); + givenProperties.put(propName, ScenarioTest.resolve(propValue == null ? null : propValue.toString(), KEEP_COMMENTS)); ScenarioTest.putProperty(propName, propValue); return this; } @@ -206,7 +210,8 @@ public abstract class UseCase> { return new PathAssertion(path); } - protected void verify( + @SafeVarargs + protected final void verify( final String title, final Supplier http, final Consumer... assertions) { @@ -236,12 +241,17 @@ public abstract class UseCase> { String resolvePlaceholders() { return ScenarioTest.resolve(template, DROP_COMMENTS); } + } 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 { @Getter @@ -319,22 +329,25 @@ public abstract class UseCase> { } @SneakyThrows - public String getFromBody(final String path) { - return JsonPath.parse(response.body()).read(ScenarioTest.resolve(path, DROP_COMMENTS)); + public 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 Optional getFromBodyAsOptional(final String path) { + public JsonOptional getFromBodyAsOptional(final String path) { try { - return Optional.ofNullable(JsonPath.parse(response.body()).read(ScenarioTest.resolve(path, DROP_COMMENTS))); + return JsonOptional.ofValue(getFromBody(path)); } catch (final PathNotFoundException e) { - return null; // means the property did not exist at all, not that it was there with value null + return JsonOptional.notGiven(); } } @SneakyThrows - public OptionalAssert path(final String path) { - return assertThat(getFromBodyAsOptional(path)); + public AbstractStringAssert path(final String path) { + return assertThat(getFromBodyAsOptional(path).givenAsString()); } @SneakyThrows @@ -396,4 +409,12 @@ public abstract class UseCase> { 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)"; + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertTransaction.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertSimpleTransaction.java similarity index 71% rename from src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertTransaction.java rename to src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertSimpleTransaction.java index 60d85fbe..0ba384a1 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertTransaction.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertSimpleTransaction.java @@ -2,15 +2,15 @@ package net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets; import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest; -public class CreateCoopAssetsRevertTransaction extends CreateCoopAssetsTransaction { +public class CreateCoopAssetsRevertSimpleTransaction extends CreateCoopAssetsTransaction { - public CreateCoopAssetsRevertTransaction(final ScenarioTest testSuite) { + public CreateCoopAssetsRevertSimpleTransaction(final ScenarioTest testSuite) { super(testSuite); requires("CoopAssets-Transaction with incorrect assetValue", alias -> new CreateCoopAssetsDepositTransaction(testSuite) .given("memberNumber", "%{memberNumber}") - .given("reference", "sign %{dateOfIncorrectTransaction}") // same as relatedAssetTx + .given("reference", "sign %{dateOfIncorrectTransaction}") // same text as relatedAssetTx .given("assetValue", 10) .given("comment", "coop-assets deposit transaction with wrong asset value") .given("transactionDate", "%{dateOfIncorrectTransaction}") @@ -21,7 +21,9 @@ public class CreateCoopAssetsRevertTransaction extends CreateCoopAssetsTransacti protected HttpResponse run() { given("transactionType", "REVERSAL"); given("assetValue", -10); + given("reference", "sign %{dateOfIncorrectTransaction}"); // same text as relatedAssetTx given("revertedAssetTx", uuid("CoopAssets-Transaction with incorrect assetValue")); + given("transactionDate", "%{dateOfIncorrectTransaction}"); return super.run(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertTransferTransaction.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertTransferTransaction.java new file mode 100644 index 00000000..56ca670c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsRevertTransferTransaction.java @@ -0,0 +1,59 @@ +package net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets; + +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest; +import net.hostsharing.hsadminng.hs.office.scenarios.UseCase; +import org.springframework.http.HttpStatus; + +import java.math.BigDecimal; + +import static net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest.resolveTyped; + +public class CreateCoopAssetsRevertTransferTransaction extends CreateCoopAssetsTransaction { + + public CreateCoopAssetsRevertTransferTransaction(final ScenarioTest testSuite) { + super(testSuite); + + requires("Accidental CoopAssets-TRANSFER-Transaction", alias -> + new CreateCoopAssetsTransferTransaction(testSuite) + .given("reference", "transfer %{dateOfIncorrectTransaction}") + .given("valueToTransfer", "%{transferredValue}") + .given("comment", "accidental transfer of assets from %{transferringMemberNumber} to %{adoptingMemberNumber}") + .given("transactionDate", "%{dateOfIncorrectTransaction}") + ); + } + + @Override + protected HttpResponse run() { + given("transactionType", "REVERSAL"); + given("assetValue", "%{transferredValue}"); + given("reference", "sign %{dateOfIncorrectTransaction}"); // same text as relatedAssetTx + given("revertedAssetTx", uuid("Accidental CoopAssets-TRANSFER-Transaction")); + given("transactionDate", "%{dateOfIncorrectTransaction}"); + return super.run(); + } + + @Override + protected void verify(final UseCase.HttpResponse response) { + super.verify(response); + + final var revertedAssetTxUuid = response.getFromBody("revertedAssetTx.uuid"); + given("negativeAssetValue", resolveTyped("%{transferredValue}", BigDecimal.class).negate()); + + verify("Verify Reverted Coop-Assets TRANSFER-Transaction", + () -> httpGet("/api/hs/office/coopassetstransactions/" + revertedAssetTxUuid) + .expecting(HttpStatus.OK).expecting(ContentType.JSON), + path("assetValue").contains("%{negativeAssetValue}"), + path("comment").contains("%{comment}"), + path("valueDate").contains("%{transactionDate}") + ); + + final var adoptionAssetTxUuid = response.getFromBody("revertedAssetTx.['adoptionAssetTx.uuid']"); + + verify("Verify Related Coop-Assets ADOPTION-Transaction Also Got Reverted", + () -> httpGet("/api/hs/office/coopassetstransactions/" + adoptionAssetTxUuid) + .expecting(HttpStatus.OK).expecting(ContentType.JSON), + path("reversalAssetTx.['transferAssetTx.uuid']").contains(revertedAssetTxUuid.toString()) + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransferTransaction.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransferTransaction.java index bceecfd8..9cfe871f 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransferTransaction.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransferTransaction.java @@ -20,7 +20,7 @@ public class CreateCoopAssetsTransferTransaction extends CreateCoopAssetsTransac ); requires("Membership: New AG", alias -> new CreateMembership(testSuite) - .given("partnerNumber", toPartnerNumber("%{adoptingMemberNumber}")) + .given("memberNumber", toPartnerNumber("%{adoptingMemberNumber}")) .given("partnerName", "New AG") .given("validFrom", "2024-11-15") .given("newStatus", "ACTIVE") @@ -34,8 +34,7 @@ public class CreateCoopAssetsTransferTransaction extends CreateCoopAssetsTransac given("memberNumber", "%{transferringMemberNumber}"); given("transactionType", "TRANSFER"); - given("assetValue", "-%{valueToDisburse}"); - given("assetValue", "-%{valueToDisburse}"); + given("assetValue", "-%{valueToTransfer}"); return super.run(); } diff --git a/src/test/java/net/hostsharing/hsadminng/lambda/WithNonNullUnitTest.java b/src/test/java/net/hostsharing/hsadminng/lambda/WithNonNullUnitTest.java new file mode 100644 index 00000000..cb947144 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/lambda/WithNonNullUnitTest.java @@ -0,0 +1,30 @@ +package net.hostsharing.hsadminng.lambda; + +import org.junit.jupiter.api.Test; + +import static net.hostsharing.hsadminng.lambda.WithNonNull.withNonNull; +import static org.assertj.core.api.Assertions.assertThat; + +class WithNonNullUnitTest { + + Boolean didRun = null; + + @Test + void withNonNullRunsBodyIfNotNull() { + didRun = false; + withNonNull("test", nonNullValue -> { + assertThat(nonNullValue).isEqualTo("test"); + didRun = true; + } ); + assertThat(didRun).isTrue(); + } + + @Test + void withNonNullDoesNotRunBodyIfNull() { + didRun = false; + withNonNull(null, nonNullValue -> { + didRun = true; + } ); + assertThat(didRun).isFalse(); + } +}