implement coop-asset-TRANSFER-transaction reversal #125

Merged
hsh-michaelhoennig merged 7 commits from feature/implement-coop-asset-transaction-reversal into master 2024-11-28 07:10:36 +01:00
14 changed files with 362 additions and 92 deletions
Showing only changes of commit 5b0c8f477c - Show all commits

View File

@ -27,11 +27,13 @@ import java.util.UUID;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import static java.util.Optional.ofNullable; 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.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.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.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.LOSS;
import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.TRANSFER; import static net.hostsharing.hsadminng.lambda.WithNonNull.withNonNull;
@RestController @RestController
public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAssetsApi { public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAssetsApi {
@ -66,7 +68,10 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
fromValueDate, fromValueDate,
toValueDate); 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); return ResponseEntity.ok(resources);
} }
@ -106,7 +111,11 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
if (result.isEmpty()) { if (result.isEmpty()) {
return ResponseEntity.notFound().build(); 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( private static void validateCreditTransaction(
final HsOfficeCoopAssetsTransactionInsertResource requestBody, final HsOfficeCoopAssetsTransactionInsertResource requestBody,
final ArrayList<String> violations) { final ArrayList<String> 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) { && requestBody.getAssetValue().signum() > 0) {
violations.add("for %s, assetValue must be negative but is \"%.2f\"".formatted( violations.add("for %s, assetValue must be negative but is \"%.2f\"".formatted(
requestBody.getTransactionType(), requestBody.getAssetValue())); requestBody.getTransactionType(), requestBody.getAssetValue()));
@ -151,53 +161,102 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
resource.setMembershipUuid(entity.getMembership().getUuid()); resource.setMembershipUuid(entity.getMembership().getUuid());
resource.setMembershipMemberNumber(entity.getMembership().getTaggedMemberNumber()); resource.setMembershipMemberNumber(entity.getMembership().getTaggedMemberNumber());
if (entity.getReversalAssetTx() != null) { withNonNull(
resource.getReversalAssetTx().setRevertedAssetTxUuid(entity.getUuid()); resource.getReversalAssetTx(), reversalAssetTxResource -> {
resource.getReversalAssetTx().setMembershipUuid(entity.getMembership().getUuid()); reversalAssetTxResource.setMembershipUuid(entity.getMembership().getUuid());
resource.getReversalAssetTx().setMembershipMemberNumber(entity.getTaggedMemberNumber()); 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) { withNonNull(
resource.getRevertedAssetTx().setReversalAssetTxUuid(entity.getUuid()); resource.getRevertedAssetTx(), revertAssetTxResource -> {
resource.getRevertedAssetTx().setMembershipUuid(entity.getMembership().getUuid()); revertAssetTxResource.setMembershipUuid(entity.getMembership().getUuid());
resource.getRevertedAssetTx().setMembershipMemberNumber(entity.getTaggedMemberNumber()); 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) { withNonNull(
resource.getAdoptionAssetTx().setTransferAssetTxUuid(entity.getUuid()); resource.getAdoptionAssetTx(), adoptionAssetTxResource -> {
resource.getAdoptionAssetTx().setMembershipUuid(entity.getAdoptionAssetTx().getMembership().getUuid()); adoptionAssetTxResource.setMembershipUuid(entity.getAdoptionAssetTx().getMembership().getUuid());
resource.getAdoptionAssetTx().setMembershipMemberNumber(entity.getAdoptionAssetTx().getTaggedMemberNumber()); adoptionAssetTxResource.setMembershipMemberNumber(entity.getAdoptionAssetTx().getTaggedMemberNumber());
} adoptionAssetTxResource.setTransferAssetTxUuid(entity.getUuid());
withNonNull(
entity.getAdoptionAssetTx().getReversalAssetTx(), reversalAssetTx ->
adoptionAssetTxResource.setReversalAssetTxUuid(reversalAssetTx.getUuid()));
});
if (entity.getTransferAssetTx() != null) { withNonNull(
resource.getTransferAssetTx().setAdoptionAssetTxUuid(entity.getUuid()); resource.getTransferAssetTx(), transferAssetTxResource -> {
resource.getTransferAssetTx().setMembershipUuid(entity.getTransferAssetTx().getMembership().getUuid()); resource.getTransferAssetTx().setMembershipUuid(entity.getTransferAssetTx().getMembership().getUuid());
resource.getTransferAssetTx().setMembershipMemberNumber(entity.getTransferAssetTx().getTaggedMemberNumber()); resource.getTransferAssetTx()
} .setMembershipMemberNumber(entity.getTransferAssetTx().getTaggedMemberNumber());
resource.getTransferAssetTx().setAdoptionAssetTxUuid(entity.getUuid());
withNonNull(
entity.getTransferAssetTx().getReversalAssetTx(), reversalAssetTx ->
transferAssetTxResource.setReversalAssetTxUuid(reversalAssetTx.getUuid()));
});
}; };
final BiConsumer<HsOfficeCoopAssetsTransactionInsertResource, HsOfficeCoopAssetsTransactionEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { final BiConsumer<HsOfficeCoopAssetsTransactionInsertResource, HsOfficeCoopAssetsTransactionEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
if (resource.getMembershipUuid() != null) { 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( .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] membership.uuid %s not found".formatted(
resource.getMembershipUuid()))); resource.getMembershipUuid())));
entity.setMembership(membership); 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()) final var revertedAssetTx = coopAssetsTransactionRepo.findByUuid(resource.getRevertedAssetTxUuid())
.orElseThrow(() -> new EntityNotFoundException("ERROR: [400] revertedEntityUuid %s not found".formatted( .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] revertedEntityUuid %s not found".formatted(
resource.getRevertedAssetTxUuid()))); resource.getRevertedAssetTxUuid())));
revertedAssetTx.setReversalAssetTx(entity);
entity.setRevertedAssetTx(revertedAssetTx); entity.setRevertedAssetTx(revertedAssetTx);
if (resource.getAssetValue().negate().compareTo(revertedAssetTx.getAssetValue()) != 0) { if (resource.getAssetValue().negate().compareTo(revertedAssetTx.getAssetValue()) != 0) {
throw new ValidationException("given assetValue=" + resource.getAssetValue() + throw new ValidationException("given assetValue=" + resource.getAssetValue() +
" but must be negative value from reverted asset tx: " + revertedAssetTx.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);
}
} }
if (resource.getTransactionType() == HsOfficeCoopAssetsTransactionTypeResource.TRANSFER) {
final var adoptingMembership = determineAdoptingMembership(resource); final var adoptingMembership = determineAdoptingMembership(resource);
if (adoptingMembership != null) { if (adoptingMembership == null) {
final var adoptingAssetTx = coopAssetsTransactionRepo.save(createAdoptingAssetTx(entity, adoptingMembership)); throw new ValidationException(
"TRANSFER asset transaction requires adoptingMembership.uuid or adoptingMembership.memberNumber");
}
final var adoptingAssetTx = createAdoptingAssetTx(entity, adoptingMembership);
entity.setAdoptionAssetTx(adoptingAssetTx); entity.setAdoptionAssetTx(adoptingAssetTx);
} }
}; };
@ -208,7 +267,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
if (adoptingMembershipUuid != null && adoptingMembershipMemberNumber != null) { if (adoptingMembershipUuid != null && adoptingMembershipMemberNumber != null) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
// @formatter:off // @formatter:off
resource.getTransactionType() == TRANSFER resource.getTransactionType() == HsOfficeCoopAssetsTransactionTypeResource.TRANSFER
? "[400] either adoptingMembership.uuid or adoptingMembership.memberNumber can be given, not both" ? "[400] either adoptingMembership.uuid or adoptingMembership.memberNumber can be given, not both"
: "[400] adoptingMembership.uuid and adoptingMembership.memberNumber must not be given for transactionType=" : "[400] adoptingMembership.uuid and adoptingMembership.memberNumber must not be given for transactionType="
+ resource.getTransactionType()); + resource.getTransactionType());
@ -232,10 +291,10 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse
+ "' not found or not accessible"); + "' not found or not accessible");
} }
if (resource.getTransactionType() == TRANSFER) { if (resource.getTransactionType() == HsOfficeCoopAssetsTransactionTypeResource.TRANSFER) {
throw new ValidationException( throw new ValidationException(
"either adoptingMembership.uuid or adoptingMembership.memberNumber must be given for transactionType=" "either adoptingMembership.uuid or adoptingMembership.memberNumber must be given for transactionType="
+ TRANSFER); + HsOfficeCoopAssetsTransactionTypeResource.TRANSFER);
} }
return null; return null;

View File

@ -98,21 +98,21 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, BaseE
private String comment; private String comment;
// Optionally, the UUID of the corresponding transaction for a reversal transaction. // 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") @JoinColumn(name = "revertedassettxuuid")
private HsOfficeCoopAssetsTransactionEntity revertedAssetTx; private HsOfficeCoopAssetsTransactionEntity revertedAssetTx;
// and the other way around // and the other way around
@OneToOne(mappedBy = "revertedAssetTx") @OneToOne(mappedBy = "revertedAssetTx", cascade = CascadeType.PERSIST)
private HsOfficeCoopAssetsTransactionEntity reversalAssetTx; private HsOfficeCoopAssetsTransactionEntity reversalAssetTx;
// Optionally, the UUID of the corresponding transaction for a transfer transaction. // 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") @JoinColumn(name = "assetadoptiontxuuid")
private HsOfficeCoopAssetsTransactionEntity adoptionAssetTx; private HsOfficeCoopAssetsTransactionEntity adoptionAssetTx;
// and the other way around // and the other way around
@OneToOne(mappedBy = "adoptionAssetTx") @OneToOne(mappedBy = "adoptionAssetTx", cascade = CascadeType.PERSIST)
private HsOfficeCoopAssetsTransactionEntity transferAssetTx; private HsOfficeCoopAssetsTransactionEntity transferAssetTx;
@Override @Override

View File

@ -0,0 +1,11 @@
package net.hostsharing.hsadminng.lambda;
import java.util.function.Consumer;
public class WithNonNull {
public static <T> void withNonNull(final T target, final Consumer<T> code) {
if (target != null ) {
code.accept(target);
}
}
}

View File

@ -35,21 +35,41 @@ create table if not exists hs_office.coopassettx
--changeset michael.hoennig:hs-office-coopassets-BUSINESS-RULES endDelimiter:--// --changeset michael.hoennig:hs-office-coopassets-BUSINESS-RULES endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
alter table hs_office.coopassettx -- Not as CHECK constraints because those cannot be deferrable,
add constraint reversal_asset_tx_must_have_reverted_asset_tx -- but we need these constraints deferrable because the rows are linked to each other.
check (transactionType <> 'REVERSAL' or revertedAssetTxUuid is not null);
alter table hs_office.coopassettx CREATE OR REPLACE FUNCTION validate_transaction_type()
add constraint non_reversal_asset_tx_must_not_have_reverted_asset_tx RETURNS TRIGGER AS $$
check (transactionType = 'REVERSAL' or revertedAssetTxUuid is null or transactionType = 'REVERSAL'); 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 -- Non-REVERSAL transactions must not have revertedAssetTxUuid
add constraint transfer_asset_tx_must_have_adopted_asset_tx IF NEW.transactionType != 'REVERSAL' AND NEW.revertedAssetTxUuid IS NOT NULL THEN
check (transactionType <> 'TRANSFER' or assetAdoptionTxUuid is not null); 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);
--// --//
-- ============================================================================ -- ============================================================================

View File

@ -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.CreateMembership;
import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets.CreateCoopAssetsDepositTransaction; 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.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.coopassets.CreateCoopAssetsTransferTransaction;
import net.hostsharing.hsadminng.hs.office.scenarios.membership.coopshares.CreateCoopSharesCancellationTransaction; 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.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.SubscribeToMailinglist;
import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import net.hostsharing.hsadminng.test.IgnoreOnFailure;
import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension; import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.MethodOrderer;
@ -395,7 +395,7 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Order(4302) @Order(4302)
@Requires("Membership: M-3101000 - Test AG") @Requires("Membership: M-3101000 - Test AG")
void shouldRevertCoopAssetsSubscription() { void shouldRevertCoopAssetsSubscription() {
new CreateCoopAssetsRevertTransaction(this) new CreateCoopAssetsRevertSimpleTransaction(this)
.given("memberNumber", "M-3101000") .given("memberNumber", "M-3101000")
.given("comment", "reverting some incorrect transaction") .given("comment", "reverting some incorrect transaction")
.given("dateOfIncorrectTransaction", "2024-02-15") .given("dateOfIncorrectTransaction", "2024-02-15")
@ -419,13 +419,13 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Test @Test
@Order(4304) @Order(4304)
@Requires("Coop-Assets M-3101000 - Test AG - DEPOSIT Transaction") @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() { void shouldTransferCoopAssets() {
new CreateCoopAssetsTransferTransaction(this) new CreateCoopAssetsTransferTransaction(this)
.given("transferringMemberNumber", "M-3101000") .given("transferringMemberNumber", "M-3101000")
.given("adoptingMemberNumber", "M-4303000") .given("adoptingMemberNumber", "M-4303000")
.given("reference", "transfer 2024-12-31") .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("comment", "transfer assets from M-3101000 to M-4303000")
.given("transactionDate", "2024-12-31") .given("transactionDate", "2024-12-31")
.doRun(); .doRun();
@ -433,11 +433,12 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Test @Test
@Order(4305) @Order(4305)
@Requires("Coop-Assets M-3101000 - Test AG - TRANSFER Transaction") @Requires("Membership M-4303000")
@IgnoreOnFailure("TODO.impl: reverting transfers is not implemented yet") void shouldRevertCoopAssetsTransferIncludingRelatedAssetAdoption() {
void shouldRevertCoopAssetsTransfer() { new CreateCoopAssetsRevertTransferTransaction(this)
new CreateCoopAssetsRevertTransaction(this) .given("transferringMemberNumber", "M-3101000")
.given("memberNumber", "M-3101000") .given("adoptingMemberNumber", "M-4303000")
.given("transferredValue", 2*64)
.given("comment", "reverting some incorrect transfer transaction") .given("comment", "reverting some incorrect transfer transaction")
.given("dateOfIncorrectTransaction", "2024-02-15") .given("dateOfIncorrectTransaction", "2024-02-15")
.doRun(); .doRun();

View File

@ -0,0 +1,44 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
public final class JsonOptional<V> {
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 <T> JsonOptional<T> ofValue(final T value) {
return new JsonOptional<>(value);
}
public static <T> JsonOptional<T> 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();
}
}

View File

@ -19,7 +19,7 @@ public class PathAssertion {
public Consumer<UseCase.HttpResponse> contains(final String resolvableValue) { public Consumer<UseCase.HttpResponse> contains(final String resolvableValue) {
return response -> { return response -> {
try { 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) { } catch (final AssertionError e) {
// without this, the error message is often lacking important context // without this, the error message is often lacking important context
fail(e.getMessage() + " in `path(\"" + path + "\").contains(\"" + resolvableValue + "\")`" ); 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();
}
} }

View File

@ -16,6 +16,7 @@ import org.springframework.boot.test.web.server.LocalServerPort;
import org.testcontainers.shaded.org.apache.commons.lang3.ObjectUtils; import org.testcontainers.shaded.org.apache.commons.lang3.ObjectUtils;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -160,6 +161,10 @@ public abstract class ScenarioTest extends ContextBasedTest {
properties.put(name, (value instanceof String string) ? resolveTyped(string) : value); properties.put(name, (value instanceof String string) ? resolveTyped(string) : value);
} }
static void removeProperty(final String propName) {
properties.remove(propName);
}
static Map<String, Object> knowVariables() { static Map<String, Object> knowVariables() {
final var map = new LinkedHashMap<String, Object>(); final var map = new LinkedHashMap<String, Object>();
ScenarioTest.aliases.forEach((key, value) -> map.put(key, value.uuid())); ScenarioTest.aliases.forEach((key, value) -> map.put(key, value.uuid()));
@ -172,8 +177,8 @@ public abstract class ScenarioTest extends ContextBasedTest {
return resolved; return resolved;
} }
public static Object resolveTyped(final String text) { public static Object resolveTyped(final String resolvableText) {
final var resolved = resolve(text, DROP_COMMENTS); final var resolved = resolve(resolvableText, DROP_COMMENTS);
try { try {
return UUID.fromString(resolved); return UUID.fromString(resolved);
} catch (final IllegalArgumentException e) { } catch (final IllegalArgumentException e) {
@ -182,4 +187,14 @@ public abstract class ScenarioTest extends ContextBasedTest {
return resolved; return resolved;
} }
public static <T> T resolveTyped(final String resolvableText, final Class<T> valueType) {
final var resolvedValue = resolve(resolvableText, DROP_COMMENTS);
if (valueType == BigDecimal.class) {
//noinspection unchecked
return (T) new BigDecimal(resolvedValue);
}
//noinspection unchecked
return (T) resolvedValue;
}
} }

View File

@ -12,9 +12,12 @@ import java.io.IOException;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import static java.lang.String.join;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
public class TestReport { public class TestReport {
@ -41,9 +44,12 @@ public class TestReport {
} }
public void createTestLogMarkdownFile(final TestInfo testInfo) throws IOException { 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(); 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)); markdownReport = new PrintWriter(new FileWriter(markdownReportFile));
print("## Scenario #" + determineScenarioTitle(testInfo)); print("## Scenario #" + determineScenarioTitle(testInfo));
} }
@ -119,6 +125,20 @@ public class TestReport {
return result.toString(); 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<String>();
while (matcher.find()) {
words.add(matcher.group(0));
}
return join(" ", words);
}
@SneakyThrows @SneakyThrows
private String currentGitBranch() { private String currentGitBranch() {
try { try {

View File

@ -9,13 +9,14 @@ import lombok.Getter;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import net.hostsharing.hsadminng.reflection.AnnotationFinder; import net.hostsharing.hsadminng.reflection.AnnotationFinder;
import org.apache.commons.collections4.map.LinkedMap; 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.hibernate.AssertionFailure;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import jakarta.validation.constraints.NotNull;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
@ -27,13 +28,13 @@ import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
import static java.net.URLEncoder.encode; 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.DROP_COMMENTS;
import static net.hostsharing.hsadminng.hs.office.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS; import static net.hostsharing.hsadminng.hs.office.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS;
import static net.hostsharing.hsadminng.test.DebuggerDetection.isDebuggerAttached; import static net.hostsharing.hsadminng.test.DebuggerDetection.isDebuggerAttached;
@ -95,6 +96,9 @@ public abstract class UseCase<T extends UseCase<?>> {
); );
final var response = run(); final var response = run();
verify(response); verify(response);
resetProperties();
return response; return response;
} }
@ -109,7 +113,7 @@ public abstract class UseCase<T extends UseCase<?>> {
} }
public final UseCase<T> given(final String propName, final Object propValue) { public final UseCase<T> 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); ScenarioTest.putProperty(propName, propValue);
return this; return this;
} }
@ -206,7 +210,8 @@ public abstract class UseCase<T extends UseCase<?>> {
return new PathAssertion(path); return new PathAssertion(path);
} }
protected void verify( @SafeVarargs
protected final void verify(
final String title, final String title,
final Supplier<UseCase.HttpResponse> http, final Supplier<UseCase.HttpResponse> http,
final Consumer<UseCase.HttpResponse>... assertions) { final Consumer<UseCase.HttpResponse>... assertions) {
@ -236,12 +241,17 @@ public abstract class UseCase<T extends UseCase<?>> {
String resolvePlaceholders() { String resolvePlaceholders() {
return ScenarioTest.resolve(template, DROP_COMMENTS); return ScenarioTest.resolve(template, DROP_COMMENTS);
} }
} }
private static Duration seconds(final int secondsIfNoDebuggerAttached) { private static Duration seconds(final int secondsIfNoDebuggerAttached) {
return isDebuggerAttached() ? Duration.ofHours(1) : Duration.ofSeconds(secondsIfNoDebuggerAttached); return isDebuggerAttached() ? Duration.ofHours(1) : Duration.ofSeconds(secondsIfNoDebuggerAttached);
} }
private void resetProperties() {
givenProperties.forEach((propName, val) -> ScenarioTest.removeProperty(propName));
}
public final class HttpResponse { public final class HttpResponse {
@Getter @Getter
@ -319,22 +329,25 @@ public abstract class UseCase<T extends UseCase<?>> {
} }
@SneakyThrows @SneakyThrows
public String getFromBody(final String path) { public <V> V getFromBody(final String path) {
return JsonPath.parse(response.body()).read(ScenarioTest.resolve(path, DROP_COMMENTS)); final var body = response.body();
final var resolvedPath = ScenarioTest.resolve(path, DROP_COMMENTS);
return JsonPath.parse(body).read(resolvedPath);
} }
@NotNull
@SneakyThrows @SneakyThrows
public <T> Optional<T> getFromBodyAsOptional(final String path) { public <V> JsonOptional<V> getFromBodyAsOptional(final String path) {
try { try {
return Optional.ofNullable(JsonPath.parse(response.body()).read(ScenarioTest.resolve(path, DROP_COMMENTS))); return JsonOptional.ofValue(getFromBody(path));
} catch (final PathNotFoundException e) { } 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 @SneakyThrows
public <T> OptionalAssert<T> path(final String path) { public AbstractStringAssert<?> path(final String path) {
return assertThat(getFromBodyAsOptional(path)); return assertThat(getFromBodyAsOptional(path).givenAsString());
} }
@SneakyThrows @SneakyThrows
@ -396,4 +409,12 @@ public abstract class UseCase<T extends UseCase<?>> {
private String title(String resultAlias) { private String title(String resultAlias) {
return getClass().getSimpleName().replaceAll("([a-z])([A-Z]+)", "$1 $2") + " => " + 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)";
}
} }

View File

@ -2,15 +2,15 @@ package net.hostsharing.hsadminng.hs.office.scenarios.membership.coopassets;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest; 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); super(testSuite);
requires("CoopAssets-Transaction with incorrect assetValue", alias -> requires("CoopAssets-Transaction with incorrect assetValue", alias ->
new CreateCoopAssetsDepositTransaction(testSuite) new CreateCoopAssetsDepositTransaction(testSuite)
.given("memberNumber", "%{memberNumber}") .given("memberNumber", "%{memberNumber}")
.given("reference", "sign %{dateOfIncorrectTransaction}") // same as relatedAssetTx .given("reference", "sign %{dateOfIncorrectTransaction}") // same text as relatedAssetTx
.given("assetValue", 10) .given("assetValue", 10)
.given("comment", "coop-assets deposit transaction with wrong asset value") .given("comment", "coop-assets deposit transaction with wrong asset value")
.given("transactionDate", "%{dateOfIncorrectTransaction}") .given("transactionDate", "%{dateOfIncorrectTransaction}")
@ -21,7 +21,9 @@ public class CreateCoopAssetsRevertTransaction extends CreateCoopAssetsTransacti
protected HttpResponse run() { protected HttpResponse run() {
given("transactionType", "REVERSAL"); given("transactionType", "REVERSAL");
given("assetValue", -10); given("assetValue", -10);
given("reference", "sign %{dateOfIncorrectTransaction}"); // same text as relatedAssetTx
given("revertedAssetTx", uuid("CoopAssets-Transaction with incorrect assetValue")); given("revertedAssetTx", uuid("CoopAssets-Transaction with incorrect assetValue"));
given("transactionDate", "%{dateOfIncorrectTransaction}");
return super.run(); return super.run();
} }
} }

View File

@ -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<CreateCoopAssetsTransaction>.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())
);
}
}

View File

@ -20,7 +20,7 @@ public class CreateCoopAssetsTransferTransaction extends CreateCoopAssetsTransac
); );
requires("Membership: New AG", alias -> new CreateMembership(testSuite) requires("Membership: New AG", alias -> new CreateMembership(testSuite)
.given("partnerNumber", toPartnerNumber("%{adoptingMemberNumber}")) .given("memberNumber", toPartnerNumber("%{adoptingMemberNumber}"))
.given("partnerName", "New AG") .given("partnerName", "New AG")
.given("validFrom", "2024-11-15") .given("validFrom", "2024-11-15")
.given("newStatus", "ACTIVE") .given("newStatus", "ACTIVE")
@ -34,8 +34,7 @@ public class CreateCoopAssetsTransferTransaction extends CreateCoopAssetsTransac
given("memberNumber", "%{transferringMemberNumber}"); given("memberNumber", "%{transferringMemberNumber}");
given("transactionType", "TRANSFER"); given("transactionType", "TRANSFER");
given("assetValue", "-%{valueToDisburse}"); given("assetValue", "-%{valueToTransfer}");
given("assetValue", "-%{valueToDisburse}");
return super.run(); return super.run();
} }

View File

@ -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();
}
}