From 3532e3a46c020084d87f7cde58056bcc3f7fc9e4 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 25 Nov 2024 16:06:24 +0100 Subject: [PATCH] add advanced scenario-tests for coop-assets (#123) Co-authored-by: Michael Hoennig Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/123 Reviewed-by: Marc Sandlus --- .aliases | 18 +- README.md | 14 +- build.gradle | 5 + ...OfficeCoopAssetsTransactionController.java | 151 +++++++++-- .../HsOfficeCoopAssetsTransactionEntity.java | 22 +- .../hostsharing/hsadminng/repr/Stringify.java | 12 +- .../hs-office-coopassets-schemas.yaml | 59 +++- .../hs-office-coopshares-schemas.yaml | 8 + .../5120-hs-office-coopassets.sql | 18 +- .../5128-hs-office-coopassets-test-data.sql | 20 +- .../hs/migration/BaseOfficeDataImport.java | 51 +++- .../hsadminng/hs/migration/CsvDataImport.java | 9 +- ...HsOfficeBankAccountControllerRestTest.java | 1 + ...tsTransactionControllerAcceptanceTest.java | 72 ++++- ...opAssetsTransactionControllerRestTest.java | 253 +++++++++++++++++- ...ceCoopAssetsTransactionEntityUnitTest.java | 20 +- ...sTransactionRepositoryIntegrationTest.java | 16 +- ...opSharesTransactionControllerRestTest.java | 1 + ...ceCoopSharesTransactionEntityUnitTest.java | 2 +- .../scenarios/HsOfficeScenarioTests.java | 52 +++- .../hs/office/scenarios/UseCase.java | 13 +- .../CreateCoopAssetsRevertTransaction.java | 4 +- .../CreateCoopAssetsTransaction.java | 3 +- .../CreateCoopAssetsTransferTransaction.java | 46 ++++ .../hsadminng/test/DebuggerDetection.java | 14 + .../hsadminng/test/IgnoreOnFailure.java | 20 ++ .../test/IgnoreOnFailureExtension.java | 52 ++++ .../hsadminng/test/TestUuidGenerator.java | 87 ++++++ 28 files changed, 942 insertions(+), 101 deletions(-) create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransferTransaction.java create mode 100644 src/test/java/net/hostsharing/hsadminng/test/DebuggerDetection.java create mode 100644 src/test/java/net/hostsharing/hsadminng/test/IgnoreOnFailure.java create mode 100644 src/test/java/net/hostsharing/hsadminng/test/IgnoreOnFailureExtension.java create mode 100644 src/test/java/net/hostsharing/hsadminng/test/TestUuidGenerator.java diff --git a/.aliases b/.aliases index 8f8fafc2..2fb7e74e 100644 --- a/.aliases +++ b/.aliases @@ -8,12 +8,20 @@ gradleWrapper () { return 1 fi - TEMPFILE=$(mktemp /tmp/gw.XXXXXX) - unbuffer ./gradlew "$@" | tee $TEMPFILE + if command -v unbuffer >/dev/null 2>&1; then + # if `unbuffer` is available in PATH, use it to print report file-URIs at the end + TEMPFILE=$(mktemp /tmp/gw.XXXXXX) + unbuffer ./gradlew "$@" | tee $TEMPFILE + echo + grep --color=never "Report:" $TEMPFILE + rm $TEMPFILE + else + # if `unbuffer` is not in PATH, simply run gradle + ./gradlew "$@" + echo "HINT: it's suggested to install 'unbuffer' to print report URIs at the end of a gradle run" + fi + - echo - grep --color=never "Report:" $TEMPFILE - rm $TEMPFILE } postgresAutodoc () { diff --git a/README.md b/README.md index 308d5c51..ed922084 100644 --- a/README.md +++ b/README.md @@ -575,7 +575,7 @@ that and creates too many (grant- and role-) rows and too even tables which coul The basic idea is always to always have a fixed set of ordered role-types which apply for all DB-tables under RBAC, e.g. OWNER>ADMIN>AGENT\[>PROXY?\]>TENENT>REFERRER. -Grants between these for the same DB-row would be implicit by order comparision. +Grants between these for the same DB-row would be implicit by order comparison. This way we would get rid of all explicit grants within the same DB-row and would not need the `rbac.role` table anymore. We would also reduce the depth of the expensive recursive CTE-query. @@ -591,8 +591,20 @@ E.g. the uuid of the target main object is often taken from an uuid of a sub-sub (For now, use `StrictMapper` to avoid this, for the case it happens.) +### Too Many Business-Rules Implemented in Controllers + +Some REST-Controllers implement too much code for business-roles. +This should be extracted to services. + + ## How To ... +Besides the following *How Tos* you can also find several *How Tos* in the source code: + +```sh +grep -r HOWTO src +``` + ### How to Configure .pgpass for the Default PostgreSQL Database? To access the default database schema as used during development, add this line to your `.pgpass` file in your users home directory: diff --git a/build.gradle b/build.gradle index 876ff878..fc287915 100644 --- a/build.gradle +++ b/build.gradle @@ -445,3 +445,8 @@ tasks.register('convertMarkdownToHtml') { } } convertMarkdownToHtml.dependsOn scenarioTests + +// shortcut for compiling all files +tasks.register('compile') { + dependsOn 'compileJava', 'compileTestJava' +} 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 06bec54e..24a1919c 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 @@ -1,10 +1,15 @@ package net.hostsharing.hsadminng.hs.office.coopassets; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopAssetsApi; -import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; import net.hostsharing.hsadminng.errors.MultiValidationException; -import net.hostsharing.hsadminng.mapper.StandardMapper; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopAssetsApi; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionInsertResource; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionResource; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipRepository; +import net.hostsharing.hsadminng.mapper.StrictMapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat.ISO; @@ -14,13 +19,19 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.ValidationException; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; -import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.*; +import static java.util.Optional.ofNullable; +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; @RestController public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAssetsApi { @@ -29,11 +40,17 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse private Context context; @Autowired - private StandardMapper mapper; + private StrictMapper mapper; + + @Autowired + private EntityManagerWrapper emw; @Autowired private HsOfficeCoopAssetsTransactionRepository coopAssetsTransactionRepo; + @Autowired + private HsOfficeMembershipRepository membershipRepo; + @Override @Transactional(readOnly = true) public ResponseEntity> getListOfCoopAssets( @@ -49,7 +66,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse fromValueDate, toValueDate); - final var resources = mapper.mapList(entities, HsOfficeCoopAssetsTransactionResource.class); + final var resources = mapper.mapList(entities, HsOfficeCoopAssetsTransactionResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(resources); } @@ -63,7 +80,10 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse context.define(currentSubject, assumedRoles); validate(requestBody); - final var entityToSave = mapper.map(requestBody, HsOfficeCoopAssetsTransactionEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + final var entityToSave = mapper.map( + requestBody, + HsOfficeCoopAssetsTransactionEntity.class, + RESOURCE_TO_ENTITY_POSTMAPPER); final var saved = coopAssetsTransactionRepo.save(entityToSave); final var uri = @@ -71,14 +91,14 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse .path("/api/hs/office/coopassetstransactions/{id}") .buildAndExpand(saved.getUuid()) .toUri(); - final var mapped = mapper.map(saved, HsOfficeCoopAssetsTransactionResource.class); + final var mapped = mapper.map(saved, HsOfficeCoopAssetsTransactionResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.created(uri).body(mapped); } @Override @Transactional(readOnly = true) public ResponseEntity getSingleCoopAssetTransactionByUuid( - final String currentSubject, final String assumedRoles, final UUID assetTransactionUuid) { + final String currentSubject, final String assumedRoles, final UUID assetTransactionUuid) { context.define(currentSubject, assumedRoles); @@ -101,7 +121,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse private static void validateDebitTransaction( final HsOfficeCoopAssetsTransactionInsertResource requestBody, final ArrayList violations) { - if (List.of(DEPOSIT, ADOPTION).contains(requestBody.getTransactionType()) + if (List.of(DEPOSIT, HsOfficeCoopAssetsTransactionTypeResource.ADOPTION).contains(requestBody.getTransactionType()) && requestBody.getAssetValue().signum() < 0) { violations.add("for %s, assetValue must be positive but is \"%.2f\"".formatted( requestBody.getTransactionType(), requestBody.getAssetValue())); @@ -127,10 +147,111 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse } } - final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { - if ( resource.getRevertedAssetTxUuid() != null ) { - entity.setRevertedAssetTx(coopAssetsTransactionRepo.findByUuid(resource.getRevertedAssetTxUuid()) - .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] reverseEntityUuid %s not found".formatted(resource.getRevertedAssetTxUuid())))); + final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { + 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()); + } + + if (entity.getRevertedAssetTx() != null) { + resource.getRevertedAssetTx().setReversalAssetTxUuid(entity.getUuid()); + resource.getRevertedAssetTx().setMembershipUuid(entity.getMembership().getUuid()); + resource.getRevertedAssetTx().setMembershipMemberNumber(entity.getTaggedMemberNumber()); + } + + if (entity.getAdoptionAssetTx() != null) { + resource.getAdoptionAssetTx().setTransferAssetTxUuid(entity.getUuid()); + resource.getAdoptionAssetTx().setMembershipUuid(entity.getAdoptionAssetTx().getMembership().getUuid()); + resource.getAdoptionAssetTx().setMembershipMemberNumber(entity.getAdoptionAssetTx().getTaggedMemberNumber()); + } + + if (entity.getTransferAssetTx() != null) { + resource.getTransferAssetTx().setAdoptionAssetTxUuid(entity.getUuid()); + resource.getTransferAssetTx().setMembershipUuid(entity.getTransferAssetTx().getMembership().getUuid()); + resource.getTransferAssetTx().setMembershipMemberNumber(entity.getTransferAssetTx().getTaggedMemberNumber()); } }; -}; + + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + + if (resource.getMembershipUuid() != null) { + 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) { + final var revertedAssetTx = coopAssetsTransactionRepo.findByUuid(resource.getRevertedAssetTxUuid()) + .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] revertedEntityUuid %s not found".formatted( + resource.getRevertedAssetTxUuid()))); + 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()); + } + } + + final var adoptingMembership = determineAdoptingMembership(resource); + if (adoptingMembership != null) { + final var adoptingAssetTx = coopAssetsTransactionRepo.save(createAdoptingAssetTx(entity, adoptingMembership)); + entity.setAdoptionAssetTx(adoptingAssetTx); + } + }; + + private HsOfficeMembershipEntity determineAdoptingMembership(final HsOfficeCoopAssetsTransactionInsertResource resource) { + final var adoptingMembershipUuid = resource.getAdoptingMembershipUuid(); + final var adoptingMembershipMemberNumber = resource.getAdoptingMembershipMemberNumber(); + if (adoptingMembershipUuid != null && adoptingMembershipMemberNumber != null) { + throw new IllegalArgumentException( + // @formatter:off + resource.getTransactionType() == 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()); + // @formatter:on + } + + if (adoptingMembershipUuid != null) { + final var adoptingMembership = membershipRepo.findByUuid(adoptingMembershipUuid); + return adoptingMembership.orElseThrow(() -> + new ValidationException( + "adoptingMembership.uuid='" + adoptingMembershipUuid + "' not found or not accessible")); + } + + if (adoptingMembershipMemberNumber != null) { + final var adoptingMemberNumber = Integer.valueOf(adoptingMembershipMemberNumber.substring("M-".length())); + final var adoptingMembership = membershipRepo.findMembershipByMemberNumber(adoptingMemberNumber); + if (adoptingMembership != null) { + return adoptingMembership; + } + throw new ValidationException("adoptingMembership.memberNumber='" + adoptingMembershipMemberNumber + + "' not found or not accessible"); + } + + if (resource.getTransactionType() == TRANSFER) { + throw new ValidationException( + "either adoptingMembership.uuid or adoptingMembership.memberNumber must be given for transactionType=" + + TRANSFER); + } + + return null; + } + + private HsOfficeCoopAssetsTransactionEntity createAdoptingAssetTx( + final HsOfficeCoopAssetsTransactionEntity transferAssetTxEntity, + final HsOfficeMembershipEntity adoptingMembership) { + return HsOfficeCoopAssetsTransactionEntity.builder() + .membership(adoptingMembership) + .transactionType(HsOfficeCoopAssetsTransactionType.ADOPTION) + .transferAssetTx(transferAssetTxEntity) + .assetValue(transferAssetTxEntity.getAssetValue().negate()) + .comment(transferAssetTxEntity.getComment()) + .reference(transferAssetTxEntity.getReference()) + .valueDate(transferAssetTxEntity.getValueDate()) + .build(); + } +} 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 cc2e504b..395c2895 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 @@ -50,8 +50,10 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, BaseE .withProp(HsOfficeCoopAssetsTransactionEntity::getAssetValue) .withProp(HsOfficeCoopAssetsTransactionEntity::getReference) .withProp(HsOfficeCoopAssetsTransactionEntity::getComment) - .withProp(at -> ofNullable(at.getRevertedAssetTx()).map(HsOfficeCoopAssetsTransactionEntity::toShortString).orElse(null)) - .withProp(at -> ofNullable(at.getReversalAssetTx()).map(HsOfficeCoopAssetsTransactionEntity::toShortString).orElse(null)) + .withProp(HsOfficeCoopAssetsTransactionEntity::getRevertedAssetTx) + .withProp(HsOfficeCoopAssetsTransactionEntity::getReversalAssetTx) + .withProp(HsOfficeCoopAssetsTransactionEntity::getAdoptionAssetTx) + .withProp(HsOfficeCoopAssetsTransactionEntity::getTransferAssetTx) .quotedValues(false); @Id @@ -95,16 +97,24 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, BaseE @Column(name = "comment") private String comment; - /** - * Optionally, the UUID of the corresponding transaction for an reversal transaction. - */ - @OneToOne + // 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 @JoinColumn(name = "revertedassettxuuid") private HsOfficeCoopAssetsTransactionEntity revertedAssetTx; + // and the other way around @OneToOne(mappedBy = "revertedAssetTx") 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 + @JoinColumn(name = "assetadoptiontxuuid") + private HsOfficeCoopAssetsTransactionEntity adoptionAssetTx; + + // and the other way around + @OneToOne(mappedBy = "adoptionAssetTx") + private HsOfficeCoopAssetsTransactionEntity transferAssetTx; + @Override public HsOfficeCoopAssetsTransactionEntity load() { BaseEntity.super.load(); diff --git a/src/main/java/net/hostsharing/hsadminng/repr/Stringify.java b/src/main/java/net/hostsharing/hsadminng/repr/Stringify.java index aaa3ea73..f1e1c709 100644 --- a/src/main/java/net/hostsharing/hsadminng/repr/Stringify.java +++ b/src/main/java/net/hostsharing/hsadminng/repr/Stringify.java @@ -80,12 +80,6 @@ public final class Stringify { .map(prop -> PropertyValue.of(prop, prop.getter.apply(object))) .filter(Objects::nonNull) .filter(PropertyValue::nonEmpty) - .map(propVal -> { - if (propVal.rawValue instanceof Stringifyable stringifyable) { - return new PropertyValue<>(propVal.prop, propVal.rawValue, stringifyable.toShortString()); - } - return propVal; - }) .map(propVal -> propName(propVal, "=") + optionallyQuoted(propVal)) .collect(Collectors.joining(separator)); return idProp != null @@ -131,7 +125,11 @@ public final class Stringify { private record PropertyValue(Property prop, Object rawValue, String value) { static PropertyValue of(Property prop, Object rawValue) { - return rawValue != null ? new PropertyValue<>(prop, rawValue, rawValue.toString()) : null; + return rawValue != null ? new PropertyValue<>(prop, rawValue, toStringOrShortString(rawValue)) : null; + } + + private static String toStringOrShortString(final Object rawValue) { + return rawValue instanceof Stringifyable stringifyable ? stringifyable.toShortString() : rawValue.toString(); } boolean nonEmpty() { diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml index 30725a82..67da1790 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml @@ -20,6 +20,15 @@ components: uuid: type: string format: uuid + membership.uuid: + type: string + format: uuid + nullable: false + membership.memberNumber: + type: string + minLength: 9 + maxLength: 9 + pattern: 'M-[0-9]{7}' transactionType: $ref: '#/components/schemas/HsOfficeCoopAssetsTransactionType' assetValue: @@ -32,20 +41,36 @@ components: type: string comment: type: string + adoptionAssetTx: + # a TRANSFER tx must refer to the related ADOPTION tx + $ref: '#/components/schemas/HsOfficeRelatedCoopAssetsTransaction' + transferAssetTx: + # an ADOPTION tx must refer to the related TRANSFER tx + $ref: '#/components/schemas/HsOfficeRelatedCoopAssetsTransaction' revertedAssetTx: - $ref: '#/components/schemas/HsOfficeReferencedCoopAssetsTransaction' + # a REVERSAL tx must refer to the related tx, which can be of any type but REVERSAL + $ref: '#/components/schemas/HsOfficeRelatedCoopAssetsTransaction' reversalAssetTx: - $ref: '#/components/schemas/HsOfficeReferencedCoopAssetsTransaction' + # a reverted tx, which can be any but REVERSAL, must refer to the related REVERSAL tx + $ref: '#/components/schemas/HsOfficeRelatedCoopAssetsTransaction' - HsOfficeReferencedCoopAssetsTransaction: + HsOfficeRelatedCoopAssetsTransaction: description: - Similar to `HsOfficeCoopAssetsTransaction` but without the self-referencing properties - (`revertedAssetTx` and `reversalAssetTx`), to avoid recursive JSON. + Similar to `HsOfficeCoopAssetsTransaction` but just the UUID of the related property, to avoid recursive JSON. type: object properties: uuid: type: string format: uuid + membership.uuid: + type: string + format: uuid + nullable: false + membership.memberNumber: + type: string + minLength: 9 + maxLength: 9 + pattern: 'M-[0-9]{7}' transactionType: $ref: '#/components/schemas/HsOfficeCoopAssetsTransactionType' assetValue: @@ -58,6 +83,22 @@ components: type: string comment: type: string + adoptionAssetTx.uuid: + description: a TRANSFER tx must refer to the related ADOPTION tx + type: string + format: uuid + transferAssetTx.uuid: + description: an ADOPTION tx must refer to the related TRANSFER tx + type: string + format: uuid + revertedAssetTx.uuid: + description: a REVERSAL tx must refer to the related tx, which can be of any type but REVERSAL + type: string + format: uuid + reversalAssetTx.uuid: + description: a reverted tx, which can be any but REVERSAL, must refer to the related REVERSAL tx + type: string + format: uuid HsOfficeCoopAssetsTransactionInsert: type: object @@ -83,6 +124,14 @@ components: revertedAssetTx.uuid: type: string format: uuid + adoptingMembership.uuid: + type: string + format: uuid + adoptingMembership.memberNumber: + type: string + minLength: 9 + maxLength: 9 + pattern: 'M-[0-9]{7}' required: - membership.uuid - transactionType diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopshares-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopshares-schemas.yaml index 8d190634..8b641d90 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-coopshares-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-coopshares-schemas.yaml @@ -16,6 +16,10 @@ components: uuid: type: string format: uuid + membership.uuid: + type: string + format: uuid + nullable: false transactionType: $ref: '#/components/schemas/HsOfficeCoopSharesTransactionType' shareCount: @@ -41,6 +45,10 @@ components: uuid: type: string format: uuid + membership.uuid: + type: string + format: uuid + nullable: false transactionType: $ref: '#/components/schemas/HsOfficeCoopSharesTransactionType' shareCount: 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 8feac5da..37c2affc 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 @@ -25,6 +25,7 @@ create table if not exists hs_office.coopassettx assetValue numeric(12,2) not null, -- https://wiki.postgresql.org/wiki/Don't_Do_This#Don.27t_use_money reference varchar(48) not null, revertedAssetTxUuid uuid unique REFERENCES hs_office.coopassettx(uuid) DEFERRABLE INITIALLY DEFERRED, + assetAdoptionTxUuid uuid unique REFERENCES hs_office.coopassettx(uuid) DEFERRABLE INITIALLY DEFERRED, comment varchar(512) ); --// @@ -35,9 +36,20 @@ create table if not exists hs_office.coopassettx -- ---------------------------------------------------------------------------- alter table hs_office.coopassettx - add constraint reverse_entry_missing - check ( transactionType = 'REVERSAL' and revertedAssetTxUuid is not null - or transactionType <> 'REVERSAL' and revertedAssetTxUuid is null); + add constraint reversal_asset_tx_must_have_reverted_asset_tx + check (transactionType <> 'REVERSAL' or revertedAssetTxUuid is not null); + +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'); + +alter table hs_office.coopassettx + add constraint transfer_asset_tx_must_have_adopted_asset_tx + check (transactionType <> 'TRANSFER' or assetAdoptionTxUuid is not null); + +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/main/resources/db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql index ac43a731..ce94ddfc 100644 --- a/src/main/resources/db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql +++ b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql @@ -15,7 +15,9 @@ create or replace procedure hs_office.coopassettx_create_test_data( language plpgsql as $$ declare membership hs_office.membership; - lossEntryUuid uuid; + invalidLossTx uuid; + transferTx uuid; + adoptionTx uuid; begin select m.uuid from hs_office.membership m @@ -25,14 +27,18 @@ begin into membership; raise notice 'creating test coopAssetsTransaction: %', givenPartnerNumber || givenMemberNumberSuffix; - lossEntryUuid := uuid_generate_v4(); + invalidLossTx := uuid_generate_v4(); + transferTx := uuid_generate_v4(); + adoptionTx := uuid_generate_v4(); insert - into hs_office.coopassettx(uuid, membershipuuid, transactiontype, valuedate, assetvalue, reference, comment, revertedAssetTxUuid) + into hs_office.coopassettx(uuid, membershipuuid, transactiontype, valuedate, assetvalue, reference, comment, revertedAssetTxUuid, assetAdoptionTxUuid) values - (uuid_generate_v4(), membership.uuid, 'DEPOSIT', '2010-03-15', 320.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-1', 'initial deposit', null), - (uuid_generate_v4(), membership.uuid, 'DISBURSAL', '2021-09-01', -128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-2', 'partial disbursal', null), - (lossEntryUuid, membership.uuid, 'DEPOSIT', '2022-10-20', 128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some loss', null), - (uuid_generate_v4(), membership.uuid, 'REVERSAL', '2022-10-21', -128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some reversal', lossEntryUuid); + (uuid_generate_v4(), membership.uuid, 'DEPOSIT', '2010-03-15', 320.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-1', 'initial deposit', null, null), + (uuid_generate_v4(), membership.uuid, 'DISBURSAL', '2021-09-01', -128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-2', 'partial disbursal', null, null), + (invalidLossTx, membership.uuid, 'DEPOSIT', '2022-10-20', 128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some loss', null, null), + (uuid_generate_v4(), membership.uuid, 'REVERSAL', '2022-10-21', -128.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some reversal', invalidLossTx, null), + (transferTx, membership.uuid, 'TRANSFER', '2023-12-31', -192.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some reversal', null, adoptionTx), + (adoptionTx, membership.uuid, 'ADOPTION', '2023-12-31', 192.00, 'ref '||givenPartnerNumber || givenMemberNumberSuffix||'-3', 'some reversal', null, null); end; $$; --// diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java index 04ebc1db..3ab17692 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java @@ -449,7 +449,7 @@ public abstract class BaseOfficeDataImport extends CsvDataImport { 1094=CoopAssetsTransaction(M-1000300: 2023-10-06, DEPOSIT, 3072, 1000300, Kapitalerhoehung - Ueberweisung), 31000=CoopAssetsTransaction(M-1002000: 2000-12-06, DEPOSIT, 128.00, 1002000, for subscription B), 32000=CoopAssetsTransaction(M-1000300: 2005-01-10, DEPOSIT, 2560.00, 1000300, for subscription C), - 33001=CoopAssetsTransaction(M-1000300: 2005-01-10, TRANSFER, -512.00, 1000300, for transfer to 10), + 33001=CoopAssetsTransaction(M-1000300: 2005-01-10, TRANSFER, -512.00, 1000300, for transfer to 10, M-1002000:ADO:+512.00), 33002=CoopAssetsTransaction(M-1002000: 2005-01-10, ADOPTION, 512.00, 1002000, for transfer from 7), 34001=CoopAssetsTransaction(M-1002000: 2016-12-31, CLEARING, -8.00, 1002000, for cancellation D), 34002=CoopAssetsTransaction(M-1002000: 2016-12-31, DISBURSAL, -100.00, 1002000, for cancellation D), @@ -877,21 +877,44 @@ public abstract class BaseOfficeDataImport extends CsvDataImport { .comment(rec.getString("comment")) .reference(member.getMemberNumber().toString()) .build(); - - if (assetTransaction.getTransactionType() == HsOfficeCoopAssetsTransactionType.REVERSAL) { - final var negativeValue = assetTransaction.getAssetValue().negate(); - final var revertedAssetTx = coopAssets.values().stream().filter(a -> - a.getTransactionType() != HsOfficeCoopAssetsTransactionType.REVERSAL && - a.getMembership() == assetTransaction.getMembership() && - a.getAssetValue().equals(negativeValue)) - .findAny() - .orElseThrow(() -> new IllegalStateException( - "cannot determine asset reverse entry for reversal " + assetTransaction)); - assetTransaction.setRevertedAssetTx(revertedAssetTx); - } - coopAssets.put(rec.getInteger("member_asset_id"), assetTransaction); }); + + coopAssets.values().forEach(assetTransaction -> { + if (assetTransaction.getTransactionType() == HsOfficeCoopAssetsTransactionType.REVERSAL) { + connectToRelatedRevertedAssetTx(assetTransaction); + } + if (assetTransaction.getTransactionType() == HsOfficeCoopAssetsTransactionType.TRANSFER) { + connectToRelatedAdoptionAssetTx(assetTransaction); + } + }); + } + + private static void connectToRelatedRevertedAssetTx(final HsOfficeCoopAssetsTransactionEntity assetTransaction) { + final var negativeValue = assetTransaction.getAssetValue().negate(); + final var revertedAssetTx = coopAssets.values().stream().filter(a -> + a.getTransactionType() != HsOfficeCoopAssetsTransactionType.REVERSAL && + a.getMembership() == assetTransaction.getMembership() && + a.getAssetValue().equals(negativeValue)) + .findAny() + .orElseThrow(() -> new IllegalStateException( + "cannot determine asset reverse entry for reversal " + assetTransaction)); + assetTransaction.setRevertedAssetTx(revertedAssetTx); + //revertedAssetTx.setAssetReversalTx(assetTransaction); + } + + private static void connectToRelatedAdoptionAssetTx(final HsOfficeCoopAssetsTransactionEntity assetTransaction) { + final var negativeValue = assetTransaction.getAssetValue().negate(); + final var adoptionAssetTx = coopAssets.values().stream().filter(a -> + a.getTransactionType() == HsOfficeCoopAssetsTransactionType.ADOPTION && + a.getMembership() != assetTransaction.getMembership() && + a.getValueDate().equals(assetTransaction.getValueDate()) && + a.getAssetValue().equals(negativeValue)) + .findAny() + .orElseThrow(() -> new IllegalStateException( + "cannot determine asset adoption entry for reversal " + assetTransaction)); + assetTransaction.setAdoptionAssetTx(adoptionAssetTx); + //adoptionAssetTx.setAssetTransferTx(assetTransaction); } private static HsOfficeMembershipEntity createOnDemandMembership(final Integer bpId) { diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java index 1f45dcce..8cfe04d7 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/CsvDataImport.java @@ -173,8 +173,13 @@ public class CsvDataImport extends ContextBasedTest { //System.out.println("persisting #" + entity.hashCode() + ": " + entity); em.persist(entity); // uncomment for debugging purposes - // em.flush(); // makes it slow, but produces better error messages - // System.out.println("persisted #" + entity.hashCode() + " as " + entity.getUuid()); + // try { + // em.flush(); // makes it slow, but produces better error messages + // System.out.println("persisted #" + entity.hashCode() + " as " + entity.getUuid()); + // return entity; + // } catch (final Exception exc) { + // throw exc; // for breakpoints + // } return entity; } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerRestTest.java index 624e9994..f17a18a7 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountControllerRestTest.java @@ -25,6 +25,7 @@ class HsOfficeBankAccountControllerRestTest { Context contextMock; @MockBean + @SuppressWarnings("unused") // not used in test, but in controller class StandardMapper mapper; @MockBean diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java index d5785ffa..bc986db0 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java @@ -69,7 +69,7 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest extends ContextBased .then().log().all().assertThat() .statusCode(200) .contentType("application/json") - .body("", hasSize(12)); // @formatter:on + .body("", hasSize(3*6)); // @formatter:on } @Test @@ -94,14 +94,22 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest extends ContextBased "assetValue": 320.00, "valueDate": "2010-03-15", "reference": "ref 1000202-1", - "comment": "initial deposit" + "comment": "initial deposit", + "adoptionAssetTx": null, + "transferAssetTx": null, + "revertedAssetTx": null, + "reversalAssetTx": null }, { "transactionType": "DISBURSAL", "assetValue": -128.00, "valueDate": "2021-09-01", "reference": "ref 1000202-2", - "comment": "partial disbursal" + "comment": "partial disbursal", + "adoptionAssetTx": null, + "transferAssetTx": null, + "revertedAssetTx": null, + "reversalAssetTx": null }, { "transactionType": "DEPOSIT", @@ -109,12 +117,18 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest extends ContextBased "valueDate": "2022-10-20", "reference": "ref 1000202-3", "comment": "some loss", + "adoptionAssetTx": null, + "transferAssetTx": null, + "revertedAssetTx": null, "reversalAssetTx": { "transactionType": "REVERSAL", "assetValue": -128.00, "valueDate": "2022-10-21", "reference": "ref 1000202-3", - "comment": "some reversal" + "comment": "some reversal", + "adoptionAssetTx.uuid": null, + "transferAssetTx.uuid": null, + "reversalAssetTx.uuid": null } }, { @@ -123,13 +137,59 @@ class HsOfficeCoopAssetsTransactionControllerAcceptanceTest extends ContextBased "valueDate": "2022-10-21", "reference": "ref 1000202-3", "comment": "some reversal", + "adoptionAssetTx": null, + "transferAssetTx": null, "revertedAssetTx": { "transactionType": "DEPOSIT", "assetValue": 128.00, "valueDate": "2022-10-20", "reference": "ref 1000202-3", - "comment": "some loss" - } + "comment": "some loss", + "adoptionAssetTx.uuid": null, + "transferAssetTx.uuid": null, + "revertedAssetTx.uuid": null + }, + "reversalAssetTx": null + }, + { + "transactionType": "TRANSFER", + "assetValue": -192.00, + "valueDate": "2023-12-31", + "reference": "ref 1000202-3", + "comment": "some reversal", + "adoptionAssetTx": { + "transactionType": "ADOPTION", + "assetValue": 192.00, + "valueDate": "2023-12-31", + "reference": "ref 1000202-3", + "comment": "some reversal", + "adoptionAssetTx.uuid": null, + "revertedAssetTx.uuid": null, + "reversalAssetTx.uuid": null + }, + "transferAssetTx": null, + "revertedAssetTx": null, + "reversalAssetTx": null + }, + { + "transactionType": "ADOPTION", + "assetValue": 192.00, + "valueDate": "2023-12-31", + "reference": "ref 1000202-3", + "comment": "some reversal", + "adoptionAssetTx": null, + "transferAssetTx": { + "transactionType": "TRANSFER", + "assetValue": -192.00, + "valueDate": "2023-12-31", + "reference": "ref 1000202-3", + "comment": "some reversal", + "transferAssetTx.uuid": null, + "revertedAssetTx.uuid": null, + "reversalAssetTx.uuid": null + }, + "revertedAssetTx": null, + "reversalAssetTx": null } ] """)); // @formatter:on diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java index 46779410..1fdbe4f0 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java @@ -1,50 +1,116 @@ package net.hostsharing.hsadminng.hs.office.coopassets; +import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.mapper.StandardMapper; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipRepository; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import net.hostsharing.hsadminng.mapper.StrictMapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; import net.hostsharing.hsadminng.rbac.test.JsonBuilder; +import net.hostsharing.hsadminng.test.TestUuidGenerator; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Optional; import java.util.UUID; import java.util.function.Function; import static net.hostsharing.hsadminng.rbac.test.JsonBuilder.jsonObject; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(HsOfficeCoopAssetsTransactionController.class) +@Import({ StrictMapper.class, JsonObjectMapperConfiguration.class }) +@RunWith(SpringRunner.class) class HsOfficeCoopAssetsTransactionControllerRestTest { + private static final UUID UNAVAILABLE_MEMBERSHIP_UUID = TestUuidGenerator.use(0); + private static final String UNAVAILABLE_MEMBER_NUMBER = "M-1234699"; + + private static final UUID ORIGIN_MEMBERSHIP_UUID = TestUuidGenerator.use(1); + private static final String ORIGIN_MEMBER_NUMBER = "M-1111100"; + public final HsOfficeMembershipEntity ORIGIN_TARGET_MEMBER_ENTITY = HsOfficeMembershipEntity.builder() + .uuid(ORIGIN_MEMBERSHIP_UUID) + .partner(HsOfficePartnerEntity.builder() + .partnerNumber(partnerNumberOf(ORIGIN_MEMBER_NUMBER)) + .build()) + .memberNumberSuffix(suffixOf(ORIGIN_MEMBER_NUMBER)) + .build(); + + private static final UUID AVAILABLE_TARGET_MEMBERSHIP_UUID = TestUuidGenerator.use(2); + private static final String AVAILABLE_TARGET_MEMBER_NUMBER = "M-1234500"; + public final HsOfficeMembershipEntity AVAILABLE_MEMBER_ENTITY = HsOfficeMembershipEntity.builder() + .uuid(AVAILABLE_TARGET_MEMBERSHIP_UUID) + .partner(HsOfficePartnerEntity.builder() + .partnerNumber(partnerNumberOf(AVAILABLE_TARGET_MEMBER_NUMBER)) + .build()) + .memberNumberSuffix(suffixOf(AVAILABLE_TARGET_MEMBER_NUMBER)) + .build(); + + // the following refs might change if impl changes + private static final UUID NEW_EXPLICITLY_CREATED_REVERSAL_ASSET_TX_UUID = TestUuidGenerator.ref(4); + private static final UUID NEW_EXPLICITLY_CREATED_TRANSFER_ASSET_TX_UUID = TestUuidGenerator.ref(5); + + private static final UUID SOME_EXISTING_LOSS_ASSET_TX_UUID = TestUuidGenerator.use(3); + public final HsOfficeCoopAssetsTransactionEntity SOME_EXISTING_LOSS_ASSET_TX_ENTITY = HsOfficeCoopAssetsTransactionEntity.builder() + .uuid(SOME_EXISTING_LOSS_ASSET_TX_UUID) + .membership(ORIGIN_TARGET_MEMBER_ENTITY) + .transactionType(HsOfficeCoopAssetsTransactionType.LOSS) + .assetValue(BigDecimal.valueOf(-64)) + .reference("some loss asset tx ref") + .comment("some loss asset tx comment") + .valueDate(LocalDate.parse("2024-10-15")) + .build(); + @Autowired MockMvc mockMvc; @MockBean Context contextMock; + @Autowired + @SuppressWarnings("unused") // not used in test, but in controller class + StrictMapper mapper; + @MockBean - StandardMapper mapper; + EntityManagerWrapper emw; // even if not used in test anymore, it's needed by base-class of StrictMapper @MockBean HsOfficeCoopAssetsTransactionRepository coopAssetsTransactionRepo; - static final String VALID_INSERT_REQUEST_BODY = """ + @MockBean + HsOfficeMembershipRepository membershipRepo; + + static final String INSERT_REQUEST_BODY_TEMPLATE = """ { "membership.uuid": "%s", "transactionType": "DEPOSIT", "assetValue": 128.00, "valueDate": "2022-10-13", "reference": "valid reference", - "comment": "valid comment" + "comment": "valid comment", + "adoptingMembership.uuid": null, + "adoptingMembership.memberNumber": null } - """.formatted(UUID.randomUUID()); + """.formatted(ORIGIN_MEMBERSHIP_UUID); enum BadRequestTestCases { MEMBERSHIP_UUID_MISSING( @@ -65,8 +131,6 @@ class HsOfficeCoopAssetsTransactionControllerRestTest { .with("assetValue", -64.00), "[for DEPOSIT, assetValue must be positive but is \"-64.00\"]"), - //TODO: other transaction types - ASSETS_VALUE_FOR_DISBURSAL_MUST_BE_NEGATIVE( requestBody -> requestBody .with("transactionType", "DISBURSAL") @@ -75,6 +139,20 @@ class HsOfficeCoopAssetsTransactionControllerRestTest { //TODO: other transaction types + ADOPTING_MEMBERSHIP_NUMBER_FOR_TRANSFER_MUST_BE_GIVEN_AND_AVAILABLE( + requestBody -> requestBody + .with("transactionType", "TRANSFER") + .with("assetValue", -64.00) + .with("adoptingMembership.memberNumber", UNAVAILABLE_MEMBER_NUMBER), + "adoptingMembership.memberNumber='M-1234699' not found or not accessible"), + + ADOPTING_MEMBERSHIP_UUID_FOR_TRANSFER_MUST_BE_GIVEN_AND_AVAILABLE( + requestBody -> requestBody + .with("transactionType", "TRANSFER") + .with("assetValue", -64.00) + .with("adoptingMembership.uuid", UNAVAILABLE_MEMBERSHIP_UUID.toString()), + "adoptingMembership.uuid='" + UNAVAILABLE_MEMBERSHIP_UUID + "' not found or not accessible"), + ASSETS_VALUE_MUST_NOT_BE_NULL( requestBody -> requestBody .with("transactionType", "REVERSAL") @@ -104,13 +182,16 @@ class HsOfficeCoopAssetsTransactionControllerRestTest { } String givenRequestBody() { - return givenBodyTransformation.apply(jsonObject(VALID_INSERT_REQUEST_BODY)).toString(); + return givenBodyTransformation.apply(jsonObject(INSERT_REQUEST_BODY_TEMPLATE)).toString(); } } @ParameterizedTest @EnumSource(BadRequestTestCases.class) void respondWithBadRequest(final BadRequestTestCases testCase) throws Exception { + // HOWTO: run just a single test-case in a data-driven test-method + // org.assertj.core.api.Assumptions.assumeThat( + // testCase == ADOPTING_MEMBERSHIP_NUMBER_FOR_TRANSFER_MUST_BE_GIVEN_AND_AVAILABLE).isTrue(); // when mockMvc.perform(MockMvcRequestBuilders @@ -127,4 +208,160 @@ class HsOfficeCoopAssetsTransactionControllerRestTest { .andExpect(status().is4xxClientError()); } + enum SuccessfullyCreatedTestCases { + + REVERTING_SIMPLE_ASSET_TRANSACTION( + requestBody -> requestBody + .with("transactionType", "REVERSAL") + .with("assetValue", "64.00") + .with("valueDate", "2024-10-15") + .with("reference", "reversal ref") + .with("comment", "reversal comment") + .with("revertedAssetTx.uuid", SOME_EXISTING_LOSS_ASSET_TX_UUID.toString()), + Expected.REVERT_RESPONSE), + + TRANSFER_TO_GIVEN_AVAILABLE_MEMBERSHIP_NUMBER( + requestBody -> requestBody + .with("transactionType", "TRANSFER") + .with("assetValue", -64.00) + .with("adoptingMembership.memberNumber", AVAILABLE_TARGET_MEMBER_NUMBER), + Expected.TRANSFER_RESPONSE), + + TRANSFER_TO_GIVEN_AVAILABLE_MEMBERSHIP_UUID( + requestBody -> requestBody + .with("transactionType", "TRANSFER") + .with("assetValue", -64.00) + .with("membership.uuid", ORIGIN_MEMBERSHIP_UUID.toString()) + .with("adoptingMembership.uuid", AVAILABLE_TARGET_MEMBERSHIP_UUID.toString()), + Expected.TRANSFER_RESPONSE); + + private final Function givenBodyTransformation; + private final String expectedResponseBody; + + SuccessfullyCreatedTestCases( + final Function givenBodyTransformation, + final String expectedResponseBody) { + this.givenBodyTransformation = givenBodyTransformation; + this.expectedResponseBody = expectedResponseBody; + } + + String givenRequestBody() { + return givenBodyTransformation.apply(jsonObject(INSERT_REQUEST_BODY_TEMPLATE)).toString(); + } + + private static class Expected { + + public static final String REVERT_RESPONSE = """ + { + "uuid": "%{NEW_EXPLICITLY_CREATED_REVERSAL_ASSET_TX_UUID}", + "membership.uuid": "%{ORIGIN_MEMBERSHIP_UUID}", + "membership.memberNumber": "%{ORIGIN_MEMBER_NUMBER}", + "transactionType": "REVERSAL", + "assetValue": 64.00, + "valueDate": "2024-10-15", + "reference": "reversal ref", + "comment": "reversal comment", + "adoptionAssetTx": null, + "transferAssetTx": null, + "revertedAssetTx": { + "uuid": "%{SOME_EXISTING_LOSS_ASSET_TX_UUID}", + "membership.uuid": "%{ORIGIN_MEMBERSHIP_UUID}", + "membership.memberNumber": "%{ORIGIN_MEMBER_NUMBER}", + "transactionType": "LOSS", + "assetValue": -64.00, + "valueDate": "2024-10-15", + "reference": "some loss asset tx ref", + "comment": "some loss asset tx comment", + "adoptionAssetTx.uuid": null, + "transferAssetTx.uuid": null, + "revertedAssetTx.uuid": null, + "reversalAssetTx.uuid": "%{NEW_EXPLICITLY_CREATED_REVERSAL_ASSET_TX_UUID}" + } + } + """ + .replace("%{NEW_EXPLICITLY_CREATED_REVERSAL_ASSET_TX_UUID}", NEW_EXPLICITLY_CREATED_REVERSAL_ASSET_TX_UUID.toString()) + .replace("%{ORIGIN_MEMBERSHIP_UUID}", ORIGIN_MEMBERSHIP_UUID.toString()) + .replace("%{ORIGIN_MEMBER_NUMBER}", ORIGIN_MEMBER_NUMBER) + .replace("%{SOME_EXISTING_LOSS_ASSET_TX_UUID}", SOME_EXISTING_LOSS_ASSET_TX_UUID.toString()); + + public static final String TRANSFER_RESPONSE = """ + { + "uuid": "%{NEW_EXPLICITLY_CREATED_TRANSFER_ASSET_TX_UUID}", + "membership.uuid": "%{ORIGIN_MEMBERSHIP_UUID}", + "membership.memberNumber": "%{ORIGIN_MEMBER_NUMBER}", + "transactionType": "TRANSFER", + "assetValue": -64.00, + "adoptionAssetTx": { + "membership.uuid": "%{AVAILABLE_MEMBERSHIP_UUID}", + "membership.memberNumber": "%{AVAILABLE_TARGET_MEMBER_NUMBER}", + "transactionType": "ADOPTION", + "assetValue": 64.00, + "transferAssetTx.uuid": "%{NEW_EXPLICITLY_CREATED_TRANSFER_ASSET_TX_UUID}" + }, + "transferAssetTx": null, + "revertedAssetTx": null, + "reversalAssetTx": null + } + """ + .replace("%{NEW_EXPLICITLY_CREATED_TRANSFER_ASSET_TX_UUID}", NEW_EXPLICITLY_CREATED_TRANSFER_ASSET_TX_UUID.toString()) + .replace("%{ORIGIN_MEMBERSHIP_UUID}", ORIGIN_MEMBERSHIP_UUID.toString()) + .replace("%{ORIGIN_MEMBER_NUMBER}", ORIGIN_MEMBER_NUMBER) + .replace("%{AVAILABLE_MEMBERSHIP_UUID}", AVAILABLE_TARGET_MEMBERSHIP_UUID.toString()) + .replace("%{AVAILABLE_TARGET_MEMBER_NUMBER}", AVAILABLE_TARGET_MEMBER_NUMBER); + } + } + + @ParameterizedTest + @EnumSource(SuccessfullyCreatedTestCases.class) + void respondWithSuccessfullyCreated(final SuccessfullyCreatedTestCases testCase) throws Exception { + // uncomment, if you need to run just a single test-case in this data-driven test-method + // org.assertj.core.api.Assumptions.assumeThat( + // testCase == ADOPTING_MEMBERSHIP_UUID_FOR_TRANSFER_MUST_BE_GIVEN_AND_AVAILABLE).isTrue(); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/office/coopassetstransactions") + .header("current-subject", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(testCase.givenRequestBody()) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("$", lenientlyEquals(testCase.expectedResponseBody))); + } + + @BeforeEach + void initMocks() { + TestUuidGenerator.start(4); + + when(emw.find(eq(HsOfficeMembershipEntity.class), eq(ORIGIN_MEMBERSHIP_UUID))).thenReturn(ORIGIN_TARGET_MEMBER_ENTITY); + when(emw.find(eq(HsOfficeMembershipEntity.class), eq(AVAILABLE_TARGET_MEMBERSHIP_UUID))).thenReturn(AVAILABLE_MEMBER_ENTITY); + + final var availableMemberNumber = Integer.valueOf(AVAILABLE_TARGET_MEMBER_NUMBER.substring("M-".length())); + when(membershipRepo.findMembershipByMemberNumber(eq(availableMemberNumber))).thenReturn(AVAILABLE_MEMBER_ENTITY); + + when(membershipRepo.findByUuid(eq(ORIGIN_MEMBERSHIP_UUID))).thenReturn(Optional.of(ORIGIN_TARGET_MEMBER_ENTITY)); + when(membershipRepo.findByUuid(eq(AVAILABLE_TARGET_MEMBERSHIP_UUID))).thenReturn(Optional.of(AVAILABLE_MEMBER_ENTITY)); + + when(coopAssetsTransactionRepo.findByUuid(SOME_EXISTING_LOSS_ASSET_TX_UUID)) + .thenReturn(Optional.of(SOME_EXISTING_LOSS_ASSET_TX_ENTITY)); + when(coopAssetsTransactionRepo.save(any(HsOfficeCoopAssetsTransactionEntity.class))) + .thenAnswer(invocation -> { + final var entity = (HsOfficeCoopAssetsTransactionEntity) invocation.getArgument(0); + if (entity.getUuid() == null) { + entity.setUuid(TestUuidGenerator.next()); + } + return entity; + } + ); + } + + private int partnerNumberOf(final String memberNumber) { + return Integer.parseInt(memberNumber.substring("M-".length(), memberNumber.length()-2)); + } + + private String suffixOf(final String memberNumber) { + return memberNumber.substring("M-".length()+5); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityUnitTest.java index 96a0edfb..cc6d2122 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityUnitTest.java @@ -20,7 +20,6 @@ class HsOfficeCoopAssetsTransactionEntityUnitTest { .comment("some comment") .build(); - final HsOfficeCoopAssetsTransactionEntity givenCoopAssetReversalTransaction = HsOfficeCoopAssetsTransactionEntity.builder() .membership(TEST_MEMBERSHIP) .reference("some-ref") @@ -31,6 +30,16 @@ class HsOfficeCoopAssetsTransactionEntityUnitTest { .revertedAssetTx(givenCoopAssetTransaction) .build(); + final HsOfficeCoopAssetsTransactionEntity givenAdoptedCoopAssetTransaction = HsOfficeCoopAssetsTransactionEntity.builder() + .membership(TEST_MEMBERSHIP) + .reference("some-ref") + .valueDate(LocalDate.parse("2020-01-15")) + .transactionType(HsOfficeCoopAssetsTransactionType.ADOPTION) + .assetValue(new BigDecimal("128.00")) + .comment("some comment") + .revertedAssetTx(givenCoopAssetTransaction) + .build(); + final HsOfficeCoopAssetsTransactionEntity givenEmptyCoopAssetsTransaction = HsOfficeCoopAssetsTransactionEntity.builder().build(); @Test @@ -49,6 +58,15 @@ class HsOfficeCoopAssetsTransactionEntityUnitTest { assertThat(result).isEqualTo("CoopAssetsTransaction(M-1000101: 2020-01-01, DEPOSIT, 128.00, some-ref, some comment, M-1000101:REV:-128.00)"); } + @Test + void toStringWithAdoptedAssetTxContainsRevertedAssetTx() { + givenCoopAssetTransaction.setAdoptionAssetTx(givenAdoptedCoopAssetTransaction); + + final var result = givenCoopAssetTransaction.toString(); + + assertThat(result).isEqualTo("CoopAssetsTransaction(M-1000101: 2020-01-01, DEPOSIT, 128.00, some-ref, some comment, M-1000101:ADO:+128.00)"); + } + @Test void toShortStringContainsOnlyMemberNumberSuffixAndSharesCountOnly() { final var result = givenCoopAssetTransaction.toShortString(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java index 4c2f866d..40f9d0a7 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java @@ -144,16 +144,22 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase "CoopAssetsTransaction(M-1000101: 2021-09-01, DISBURSAL, -128.00, ref 1000101-2, partial disbursal)", "CoopAssetsTransaction(M-1000101: 2022-10-20, DEPOSIT, 128.00, ref 1000101-3, some loss, M-1000101:REV:-128.00)", "CoopAssetsTransaction(M-1000101: 2022-10-21, REVERSAL, -128.00, ref 1000101-3, some reversal, M-1000101:DEP:+128.00)", + "CoopAssetsTransaction(M-1000101: 2023-12-31, ADOPTION, 192.00, ref 1000101-3, some reversal, M-1000101:TRA:-192.00)", + "CoopAssetsTransaction(M-1000101: 2023-12-31, TRANSFER, -192.00, ref 1000101-3, some reversal, M-1000101:ADO:+192.00)", "CoopAssetsTransaction(M-1000202: 2010-03-15, DEPOSIT, 320.00, ref 1000202-1, initial deposit)", "CoopAssetsTransaction(M-1000202: 2021-09-01, DISBURSAL, -128.00, ref 1000202-2, partial disbursal)", "CoopAssetsTransaction(M-1000202: 2022-10-20, DEPOSIT, 128.00, ref 1000202-3, some loss, M-1000202:REV:-128.00)", "CoopAssetsTransaction(M-1000202: 2022-10-21, REVERSAL, -128.00, ref 1000202-3, some reversal, M-1000202:DEP:+128.00)", + "CoopAssetsTransaction(M-1000202: 2023-12-31, TRANSFER, -192.00, ref 1000202-3, some reversal, M-1000202:ADO:+192.00)", + "CoopAssetsTransaction(M-1000202: 2023-12-31, ADOPTION, 192.00, ref 1000202-3, some reversal, M-1000202:TRA:-192.00)", "CoopAssetsTransaction(M-1000303: 2010-03-15, DEPOSIT, 320.00, ref 1000303-1, initial deposit)", "CoopAssetsTransaction(M-1000303: 2021-09-01, DISBURSAL, -128.00, ref 1000303-2, partial disbursal)", "CoopAssetsTransaction(M-1000303: 2022-10-20, DEPOSIT, 128.00, ref 1000303-3, some loss, M-1000303:REV:-128.00)", - "CoopAssetsTransaction(M-1000303: 2022-10-21, REVERSAL, -128.00, ref 1000303-3, some reversal, M-1000303:DEP:+128.00)"); + "CoopAssetsTransaction(M-1000303: 2022-10-21, REVERSAL, -128.00, ref 1000303-3, some reversal, M-1000303:DEP:+128.00)", + "CoopAssetsTransaction(M-1000303: 2023-12-31, TRANSFER, -192.00, ref 1000303-3, some reversal, M-1000303:ADO:+192.00)", + "CoopAssetsTransaction(M-1000303: 2023-12-31, ADOPTION, 192.00, ref 1000303-3, some reversal, M-1000303:TRA:-192.00)"); } @Test @@ -174,7 +180,9 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase "CoopAssetsTransaction(M-1000202: 2010-03-15, DEPOSIT, 320.00, ref 1000202-1, initial deposit)", "CoopAssetsTransaction(M-1000202: 2021-09-01, DISBURSAL, -128.00, ref 1000202-2, partial disbursal)", "CoopAssetsTransaction(M-1000202: 2022-10-20, DEPOSIT, 128.00, ref 1000202-3, some loss, M-1000202:REV:-128.00)", - "CoopAssetsTransaction(M-1000202: 2022-10-21, REVERSAL, -128.00, ref 1000202-3, some reversal, M-1000202:DEP:+128.00)"); + "CoopAssetsTransaction(M-1000202: 2022-10-21, REVERSAL, -128.00, ref 1000202-3, some reversal, M-1000202:DEP:+128.00)", + "CoopAssetsTransaction(M-1000202: 2023-12-31, TRANSFER, -192.00, ref 1000202-3, some reversal, M-1000202:ADO:+192.00)", + "CoopAssetsTransaction(M-1000202: 2023-12-31, ADOPTION, 192.00, ref 1000202-3, some reversal, M-1000202:TRA:-192.00)"); } @Test @@ -212,7 +220,9 @@ class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBase "CoopAssetsTransaction(M-1000101: 2010-03-15, DEPOSIT, 320.00, ref 1000101-1, initial deposit)", "CoopAssetsTransaction(M-1000101: 2021-09-01, DISBURSAL, -128.00, ref 1000101-2, partial disbursal)", "CoopAssetsTransaction(M-1000101: 2022-10-20, DEPOSIT, 128.00, ref 1000101-3, some loss, M-1000101:REV:-128.00)", - "CoopAssetsTransaction(M-1000101: 2022-10-21, REVERSAL, -128.00, ref 1000101-3, some reversal, M-1000101:DEP:+128.00)"); + "CoopAssetsTransaction(M-1000101: 2022-10-21, REVERSAL, -128.00, ref 1000101-3, some reversal, M-1000101:DEP:+128.00)", + "CoopAssetsTransaction(M-1000101: 2023-12-31, TRANSFER, -192.00, ref 1000101-3, some reversal, M-1000101:ADO:+192.00)", + "CoopAssetsTransaction(M-1000101: 2023-12-31, ADOPTION, 192.00, ref 1000101-3, some reversal, M-1000101:TRA:-192.00)"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerRestTest.java index 821e8871..6da7ddd1 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionControllerRestTest.java @@ -30,6 +30,7 @@ class HsOfficeCoopSharesTransactionControllerRestTest { Context contextMock; @MockBean + @SuppressWarnings("unused") // not used in test, but in controller class StandardMapper mapper; @MockBean diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntityUnitTest.java index 6394a8d7..bef9af31 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionEntityUnitTest.java @@ -40,7 +40,7 @@ class HsOfficeCoopSharesTransactionEntityUnitTest { } @Test - void toStringWithRevertedAssetTxContainsRevertedAssetTx() { + void toStringWithRelatedAssetTxContainsRelatedAssetTx() { givenCoopSharesTransaction.setRevertedShareTx(givenCoopShareReversalTransaction); final var result = givenCoopSharesTransaction.toString(); 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 0f7362df..dec419f0 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 @@ -17,6 +17,7 @@ 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.CreateCoopAssetsTransferTransaction; import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopshares.CreateCoopSharesCancellationTransaction; import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopshares.CreateCoopSharesRevertTransaction; import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopshares.CreateCoopSharesSubscriptionTransaction; @@ -29,12 +30,15 @@ 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; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; @@ -51,6 +55,7 @@ import org.springframework.test.annotation.DirtiesContext; ) @DirtiesContext @TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@ExtendWith(IgnoreOnFailureExtension.class) class HsOfficeScenarioTests extends ScenarioTest { @Test @@ -77,8 +82,8 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(1011) - @Produces(explicitly = "Partner: P-31011 - Michelle Matthieu", implicitly = { "Person: Michelle Matthieu", - "Contact: Michelle Matthieu" }) + @Produces(explicitly = "Partner: P-31011 - Michelle Matthieu", + implicitly = { "Person: Michelle Matthieu", "Contact: Michelle Matthieu" }) void shouldCreateNaturalPersonAsPartner() { new CreatePartner(this) .given("partnerNumber", "P-31011") @@ -336,7 +341,7 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(4201) @Requires("Membership: M-3101000 - Test AG") - @Produces("Coop-Shares SUBSCRIPTION Transaction") + @Produces("Coop-Shares M-3101000 - Test AG - SUBSCRIPTION Transaction") void shouldSubscribeCoopShares() { new CreateCoopSharesSubscriptionTransaction(this) .given("memberNumber", "M-3101000") @@ -360,8 +365,8 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(4202) - @Requires("Coop-Shares SUBSCRIPTION Transaction") - @Produces("Coop-Shares CANCELLATION Transaction") + @Requires("Coop-Shares M-3101000 - Test AG - SUBSCRIPTION Transaction") + @Produces("Coop-Shares M-3101000 - Test AG - CANCELLATION Transaction") void shouldCancelCoopSharesSubscription() { new CreateCoopSharesCancellationTransaction(this) .given("memberNumber", "M-3101000") @@ -375,7 +380,7 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(4301) @Requires("Membership: M-3101000 - Test AG") - @Produces("Coop-Assets DEPOSIT Transaction") + @Produces("Coop-Assets M-3101000 - Test AG - DEPOSIT Transaction") void shouldSubscribeCoopAssets() { new CreateCoopAssetsDepositTransaction(this) .given("memberNumber", "M-3101000") @@ -388,7 +393,7 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(4302) - @Requires("Coop-Assets DEPOSIT Transaction") + @Requires("Membership: M-3101000 - Test AG") void shouldRevertCoopAssetsSubscription() { new CreateCoopAssetsRevertTransaction(this) .given("memberNumber", "M-3101000") @@ -398,9 +403,9 @@ class HsOfficeScenarioTests extends ScenarioTest { } @Test - @Order(4302) - @Requires("Coop-Assets DEPOSIT Transaction") - @Produces("Coop-Assets DISBURSAL Transaction") + @Order(4303) + @Requires("Coop-Assets M-3101000 - Test AG - DEPOSIT Transaction") + @Produces("Coop-Assets M-3101000 - Test AG - DISBURSAL Transaction") void shouldDisburseCoopAssets() { new CreateCoopAssetsDisbursalTransaction(this) .given("memberNumber", "M-3101000") @@ -411,6 +416,33 @@ class HsOfficeScenarioTests extends ScenarioTest { .doRun(); } + @Test + @Order(4304) + @Requires("Coop-Assets M-3101000 - Test AG - DEPOSIT Transaction") + @Produces("Coop-Assets M-3101000 - Test AG - TRANSFER Transaction") + void shouldTransferCoopAssets() { + new CreateCoopAssetsTransferTransaction(this) + .given("transferringMemberNumber", "M-3101000") + .given("adoptingMemberNumber", "M-4303000") + .given("reference", "transfer 2024-12-31") + .given("valueToDisburse", 2 * 64) + .given("comment", "transfer assets from M-3101000 to M-4303000") + .given("transactionDate", "2024-12-31") + .doRun(); + } + + @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") + .given("comment", "reverting some incorrect transfer transaction") + .given("dateOfIncorrectTransaction", "2024-02-15") + .doRun(); + } + @Test @Order(4900) @Requires("Membership: M-3101000 - Test AG") 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 23244d64..27e38e13 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 @@ -36,6 +36,7 @@ import java.util.function.Supplier; import static java.net.URLEncoder.encode; 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; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.platform.commons.util.StringUtils.isBlank; @@ -151,7 +152,7 @@ public abstract class UseCase> { .GET() .uri(new URI("http://localhost:" + testSuite.port + uriPath)) .header("current-subject", ScenarioTest.RUN_AS_USER) - .timeout(Duration.ofSeconds(10)) + .timeout(seconds(10)) .build(); final var response = client.send(request, BodyHandlers.ofString()); return new HttpResponse(HttpMethod.GET, uriPath, null, response); @@ -166,7 +167,7 @@ public abstract class UseCase> { .uri(new URI("http://localhost:" + testSuite.port + uriPath)) .header("Content-Type", "application/json") .header("current-subject", ScenarioTest.RUN_AS_USER) - .timeout(Duration.ofSeconds(10)) + .timeout(seconds(10)) .build(); final var response = client.send(request, BodyHandlers.ofString()); return new HttpResponse(HttpMethod.POST, uriPath, requestBody, response); @@ -181,7 +182,7 @@ public abstract class UseCase> { .uri(new URI("http://localhost:" + testSuite.port + uriPath)) .header("Content-Type", "application/json") .header("current-subject", ScenarioTest.RUN_AS_USER) - .timeout(Duration.ofSeconds(10)) + .timeout(seconds(10)) .build(); final var response = client.send(request, BodyHandlers.ofString()); return new HttpResponse(HttpMethod.PATCH, uriPath, requestBody, response); @@ -195,7 +196,7 @@ public abstract class UseCase> { .uri(new URI("http://localhost:" + testSuite.port + uriPath)) .header("Content-Type", "application/json") .header("current-subject", ScenarioTest.RUN_AS_USER) - .timeout(Duration.ofSeconds(10)) + .timeout(seconds(10)) .build(); final var response = client.send(request, BodyHandlers.ofString()); return new HttpResponse(HttpMethod.DELETE, uriPath, null, response); @@ -237,6 +238,10 @@ public abstract class UseCase> { } } + private static Duration seconds(final int secondsIfNoDebuggerAttached) { + return isDebuggerAttached() ? Duration.ofHours(1) : Duration.ofSeconds(secondsIfNoDebuggerAttached); + } + public final class HttpResponse { @Getter 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/CreateCoopAssetsRevertTransaction.java index a83c21a2..60d85fbe 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/CreateCoopAssetsRevertTransaction.java @@ -10,7 +10,7 @@ public class CreateCoopAssetsRevertTransaction extends CreateCoopAssetsTransacti requires("CoopAssets-Transaction with incorrect assetValue", alias -> new CreateCoopAssetsDepositTransaction(testSuite) .given("memberNumber", "%{memberNumber}") - .given("reference", "sign %{dateOfIncorrectTransaction}") // same as revertedAssetTx + .given("reference", "sign %{dateOfIncorrectTransaction}") // same as relatedAssetTx .given("assetValue", 10) .given("comment", "coop-assets deposit transaction with wrong asset value") .given("transactionDate", "%{dateOfIncorrectTransaction}") @@ -20,7 +20,7 @@ public class CreateCoopAssetsRevertTransaction extends CreateCoopAssetsTransacti @Override protected HttpResponse run() { given("transactionType", "REVERSAL"); - given("assetValue", -100); + given("assetValue", -10); given("revertedAssetTx", uuid("CoopAssets-Transaction with incorrect assetValue")); return super.run(); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransaction.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransaction.java index 56bc2a55..fbe4b8d5 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransaction.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/coopassets/CreateCoopAssetsTransaction.java @@ -32,7 +32,8 @@ public abstract class CreateCoopAssetsTransaction extends UseCase new CreatePartner(testSuite, alias) + .given("partnerNumber", toPartnerNumber("%{adoptingMemberNumber}")) + .given("personType", "LEGAL_PERSON") + .given("tradeName", "New AG") + .given("contactCaption", "New AG - Board of Directors") + .given("emailAddress", "board-of-directors@new-ag.example.org") + ); + + requires("Membership: New AG", alias -> new CreateMembership(testSuite) + .given("partnerNumber", toPartnerNumber("%{adoptingMemberNumber}")) + .given("partnerName", "New AG") + .given("validFrom", "2024-11-15") + .given("newStatus", "ACTIVE") + .given("membershipFeeBillable", "true") + ); + } + + @Override + protected HttpResponse run() { + introduction("Additionally to the TRANSFER, the ADOPTION is automatically booked for the receiving member."); + + given("memberNumber", "%{transferringMemberNumber}"); + given("transactionType", "TRANSFER"); + given("assetValue", "-%{valueToDisburse}"); + given("assetValue", "-%{valueToDisburse}"); + return super.run(); + } + + private String toPartnerNumber(final String resolvableString) { + final var memberNumber = ScenarioTest.resolve(resolvableString, DROP_COMMENTS); + return "P-" + memberNumber.substring("M-".length(), 7); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/test/DebuggerDetection.java b/src/test/java/net/hostsharing/hsadminng/test/DebuggerDetection.java new file mode 100644 index 00000000..49fcaec6 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/test/DebuggerDetection.java @@ -0,0 +1,14 @@ +package net.hostsharing.hsadminng.test; + +import lombok.experimental.UtilityClass; + +import java.lang.management.ManagementFactory; + +@UtilityClass +public class DebuggerDetection { + public static boolean isDebuggerAttached() { + // check for typical debug arguments in the JVM input arguments + return ManagementFactory.getRuntimeMXBean().getInputArguments().stream() + .anyMatch(arg -> arg.contains("-agentlib:jdwp")); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/test/IgnoreOnFailure.java b/src/test/java/net/hostsharing/hsadminng/test/IgnoreOnFailure.java new file mode 100644 index 00000000..6a2a885d --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/test/IgnoreOnFailure.java @@ -0,0 +1,20 @@ +package net.hostsharing.hsadminng.test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Use this annotation on JUnit Jupiter test-methods to convert failure to ignore. + * + *

+ * The test-class also has to add the extension {link IgnoreOnFailureExtension}. + *

+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface IgnoreOnFailure { + /// a comment, e.g. about the feature under construction + String value() default ""; +} diff --git a/src/test/java/net/hostsharing/hsadminng/test/IgnoreOnFailureExtension.java b/src/test/java/net/hostsharing/hsadminng/test/IgnoreOnFailureExtension.java new file mode 100644 index 00000000..511b89fd --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/test/IgnoreOnFailureExtension.java @@ -0,0 +1,52 @@ +package net.hostsharing.hsadminng.test; + +import org.junit.jupiter.api.extension.ExtensionContext; + +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assumptions.assumeThat; + +/** + * Use this JUnit Jupiter extension to ignore failing tests annotated with annotation {@link IgnoreOnFailure}. + * + *

+ * This is useful for outside-in-TDD, if you write a high-level (e.g. Acceptance- or Scenario-Test) before + * you even have an implementation for that new feature. + * As long as no other tests breaks, it's not a real problem merging your new test and incomplete implementation. + *

+ *

+ * Once the test turns green, remove the annotation {@link IgnoreOnFailure}. + *

+ * + */ +// BLOG: A JUnit Jupiter extension to ignore failed acceptance tests for outside-in TDD +public class IgnoreOnFailureExtension implements InvocationInterceptor { + + /// @hidden + @Override + public void interceptTestMethod( + final Invocation invocation, + final ReflectiveInvocationContext invocationContext, + final ExtensionContext extensionContext) throws Throwable { + + try { + invocation.proceed(); + } catch (final Throwable throwable) { + if (hasIgnoreOnFailureAnnotation(extensionContext)) { + assumeThat(true).as("ignoring failed test with @" + IgnoreOnFailure.class.getSimpleName()).isFalse(); + } else { + throw throwable; + } + } + } + + private static boolean hasIgnoreOnFailureAnnotation(final ExtensionContext context) { + final var hasIgnoreOnFailureAnnotation = context.getTestMethod() + .map(method -> method.getAnnotation(IgnoreOnFailure.class)) + .isPresent(); + return hasIgnoreOnFailureAnnotation; + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/test/TestUuidGenerator.java b/src/test/java/net/hostsharing/hsadminng/test/TestUuidGenerator.java new file mode 100644 index 00000000..b75424c1 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/test/TestUuidGenerator.java @@ -0,0 +1,87 @@ +package net.hostsharing.hsadminng.test; + +import lombok.experimental.UtilityClass; +import org.jetbrains.annotations.NotNull; + +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; + +@UtilityClass +public class TestUuidGenerator { + + private static final UUID ZEROES_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + private static final List GIVEN_UUIDS = List.of( + ZEROES_UUID, + uuidWithDigit(1), + uuidWithDigit(2), + uuidWithDigit(3), + uuidWithDigit(4), + uuidWithDigit(5), + uuidWithDigit(6), + uuidWithDigit(7), + uuidWithDigit(8), + uuidWithDigit(9), + uuidWithChar('a'), + uuidWithChar('b'), + uuidWithChar('c'), + uuidWithChar('d'), + uuidWithChar('e'), + uuidWithChar('f') + ); + + private static Set staticallyUsedIndexes = new HashSet<>(); + + private Queue availableUuids = null; + + + public static void start(final int firstIndex) { + if (staticallyUsedIndexes.contains(firstIndex)) { + throw new IllegalArgumentException(firstIndex + " already used statically, try higher and amend references"); + } + availableUuids = new LinkedList<>(GIVEN_UUIDS.subList(firstIndex, GIVEN_UUIDS.size())); + } + + public static UUID next() { + if (availableUuids == null) { + throw new IllegalStateException("UUID generator not started yet, call start() in @BeforeEach."); + } + if (availableUuids.isEmpty()) { + throw new IllegalStateException("No UUIDs available anymore."); + } + return availableUuids.poll(); + } + + /** + * Marks the UUID as used in static initializers. + * + * @param index 0..15 + * @return a constant UUID related to the given index + */ + public static UUID use(final int index) { + staticallyUsedIndexes.add(index); + return GIVEN_UUIDS.get(index); + } + + /** + * References the UUID from the given index. + * + * @param index 0..15 + * @return a constant UUID related to the given index + */ + public static UUID ref(final int index) { + return GIVEN_UUIDS.get(index); + } + + private static @NotNull UUID uuidWithDigit(final int digit) { + return UUID.fromString(ZEROES_UUID.toString().replace('0', Character.forDigit(digit, 16))); + } + + private static @NotNull UUID uuidWithChar(final char hexDigit) { + return UUID.fromString(ZEROES_UUID.toString().replace('0', hexDigit)); + } +}