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 new file mode 100644 index 00000000..c025da5d --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java @@ -0,0 +1,118 @@ +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.HsOfficeCoopAssetsTransactionInsertResource; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionResource; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import javax.validation.Valid; +import javax.validation.ValidationException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static java.lang.String.join; +import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.*; +import static net.hostsharing.hsadminng.mapper.Mapper.map; + +@RestController +public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAssetsApi { + + @Autowired + private Context context; + + @Autowired + private HsOfficeCoopAssetsTransactionRepository coopAssetsTransactionRepo; + + @Override + @Transactional(readOnly = true) + public ResponseEntity> listCoopAssets( + final String currentUser, + final String assumedRoles, + final UUID membershipUuid, + final @DateTimeFormat(iso = ISO.DATE) LocalDate fromValueDate, + final @DateTimeFormat(iso = ISO.DATE) LocalDate toValueDate) { + context.define(currentUser, assumedRoles); + + final var entities = coopAssetsTransactionRepo.findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( + membershipUuid, + fromValueDate, + toValueDate); + + final var resources = Mapper.mapList(entities, HsOfficeCoopAssetsTransactionResource.class); + return ResponseEntity.ok(resources); + } + + @Override + @Transactional + public ResponseEntity addCoopAssetsTransaction( + final String currentUser, + final String assumedRoles, + @Valid final HsOfficeCoopAssetsTransactionInsertResource requestBody) { + + context.define(currentUser, assumedRoles); + validate(requestBody); + + final var entityToSave = map(requestBody, HsOfficeCoopAssetsTransactionEntity.class); + entityToSave.setUuid(UUID.randomUUID()); + + final var saved = coopAssetsTransactionRepo.save(entityToSave); + + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/office/coopassetstransactions/{id}") + .buildAndExpand(entityToSave.getUuid()) + .toUri(); + final var mapped = map(saved, HsOfficeCoopAssetsTransactionResource.class); + return ResponseEntity.created(uri).body(mapped); + } + + private void validate(final HsOfficeCoopAssetsTransactionInsertResource requestBody) { + final var violations = new ArrayList(); + validateDebitTransaction(requestBody, violations); + validateCreditTransaction(requestBody, violations); + validateAssetValue(requestBody, violations); + if (violations.size() > 0) { + throw new ValidationException("[" + join(", ", violations) + "]"); + } + } + + private static void validateDebitTransaction( + final HsOfficeCoopAssetsTransactionInsertResource requestBody, + final ArrayList violations) { + if (List.of(DEPOSIT, ADOPTION).contains(requestBody.getTransactionType()) + && requestBody.getAssetValue().signum() < 0) { + violations.add("for %s, assetValue must be positive but is \"%.2f\"".formatted( + requestBody.getTransactionType(), requestBody.getAssetValue())); + } + } + + private static void validateCreditTransaction( + final HsOfficeCoopAssetsTransactionInsertResource requestBody, + final ArrayList violations) { + if (List.of(DISBURSAL, TRANSFER, CLEARING, LOSS).contains(requestBody.getTransactionType()) + && requestBody.getAssetValue().signum() > 0) { + violations.add("for %s, assetValue must be negative but is \"%.2f\"".formatted( + requestBody.getTransactionType(), requestBody.getAssetValue())); + } + } + + private static void validateAssetValue( + final HsOfficeCoopAssetsTransactionInsertResource requestBody, + final ArrayList violations) { + if (requestBody.getAssetValue().signum() == 0) { + violations.add("assetValue must not be 0 but is \"%.2f\"".formatted( + requestBody.getAssetValue())); + } + } + +} 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 new file mode 100644 index 00000000..baf27c76 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java @@ -0,0 +1,75 @@ +package net.hostsharing.hsadminng.hs.office.coopassets; + +import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType; +import lombok.*; +import net.hostsharing.hsadminng.errors.DisplayName; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity; +import net.hostsharing.hsadminng.stringify.Stringify; +import net.hostsharing.hsadminng.stringify.Stringifyable; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; + +import javax.persistence.*; +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.time.LocalDate; +import java.util.UUID; + +import static net.hostsharing.hsadminng.stringify.Stringify.stringify; + +@Entity +@Table(name = "hs_office_coopassetstransaction_rv") +@TypeDef( + name = "pgsql_enum", + typeClass = PostgreSQLEnumType.class +) +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DisplayName("CoopAssetsTransaction") +public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable { + + private static Stringify stringify = stringify(HsOfficeCoopAssetsTransactionEntity.class) + .withProp(e -> e.getMembership().getMemberNumber()) + .withProp(HsOfficeCoopAssetsTransactionEntity::getValueDate) + .withProp(HsOfficeCoopAssetsTransactionEntity::getTransactionType) + .withProp(HsOfficeCoopAssetsTransactionEntity::getAssetValue) + .withProp(HsOfficeCoopAssetsTransactionEntity::getReference) + .withSeparator(", ") + .quotedValues(false); + + private @Id UUID uuid; + + @ManyToOne + @JoinColumn(name = "membershipuuid") + private HsOfficeMembershipEntity membership; + + @Column(name = "transactiontype") + @Enumerated(EnumType.STRING) + @Type( type = "pgsql_enum" ) + private HsOfficeCoopAssetsTransactionType transactionType; + + @Column(name = "valuedate") + private LocalDate valueDate; + + @Column(name = "assetvalue") + private BigDecimal assetValue; + + @Column(name = "reference") + private String reference; + + @Column(name = "comment") + private String comment; + + @Override + public String toString() { + return stringify.apply(this); + } + + @Override + public String toShortString() { + return membership.getMemberNumber() + new DecimalFormat("+0.00").format(assetValue); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepository.java new file mode 100644 index 00000000..1a14abde --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepository.java @@ -0,0 +1,29 @@ +package net.hostsharing.hsadminng.hs.office.coopassets; + +import net.hostsharing.hsadminng.hs.office.coopshares.HsOfficeCoopSharesTransactionEntity; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsOfficeCoopAssetsTransactionRepository extends Repository { + + Optional findByUuid(UUID id); + + @Query(""" + SELECT at FROM HsOfficeCoopAssetsTransactionEntity at + WHERE ( CAST(:membershipUuid AS org.hibernate.type.UUIDCharType) IS NULL OR at.membership.uuid = :membershipUuid) + AND ( CAST(:fromValueDate AS java.time.LocalDate) IS NULL OR (at.valueDate >= :fromValueDate)) + AND ( CAST(:toValueDate AS java.time.LocalDate)IS NULL OR (at.valueDate <= :toValueDate)) + ORDER BY at.membership.memberNumber, at.valueDate + """) + List findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( + UUID membershipUuid, LocalDate fromValueDate, LocalDate toValueDate); + + HsOfficeCoopAssetsTransactionEntity save(final HsOfficeCoopAssetsTransactionEntity entity); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionType.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionType.java new file mode 100644 index 00000000..cc386966 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionType.java @@ -0,0 +1,5 @@ +package net.hostsharing.hsadminng.hs.office.coopassets; + +public enum HsOfficeCoopAssetsTransactionType { + ADJUSTMENT, DEPOSIT, DISBURSAL, TRANSFER, ADOPTION, CLEARING, LOSS +} diff --git a/src/main/resources/api-definition/hs-office/api-mappings.yaml b/src/main/resources/api-definition/hs-office/api-mappings.yaml index 9d378b16..8f6f076e 100644 --- a/src/main/resources/api-definition/hs-office/api-mappings.yaml +++ b/src/main/resources/api-definition/hs-office/api-mappings.yaml @@ -12,6 +12,7 @@ map: - type: array => java.util.List - type: string:uuid => java.util.UUID - type: string:format => java.lang.String + - type: number:currency => java.math.BigDecimal paths: /api/hs/office/partners/{partnerUUID}: 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 new file mode 100644 index 00000000..adfcc9e8 --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml @@ -0,0 +1,63 @@ + +components: + + schemas: + + HsOfficeCoopAssetsTransactionType: + type: string + enum: + - ADJUSTMENT + - DEPOSIT + - DISBURSAL + - TRANSFER + - ADOPTION + - CLEARING + - LOSS + + HsOfficeCoopAssetsTransaction: + type: object + properties: + uuid: + type: string + format: uuid + transactionType: + $ref: '#/components/schemas/HsOfficeCoopAssetsTransactionType' + assetValue: + type: number + format: currency + valueDate: + type: string + format: date + reference: + type: string + comment: + type: string + + HsOfficeCoopAssetsTransactionInsert: + type: object + properties: + membershipUuid: + type: string + format: uuid + nullable: false + transactionType: + $ref: '#/components/schemas/HsOfficeCoopAssetsTransactionType' + assetValue: + type: number + format: currency + valueDate: + type: string + format: date + reference: + type: string + minLength: 6 + maxLength: 48 + comment: + type: string + required: + - membershipUuid + - transactionType + - assetValue + - valueDate + - reference + additionalProperties: false diff --git a/src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml b/src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml new file mode 100644 index 00000000..75b19f7f --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml @@ -0,0 +1,72 @@ +get: + summary: Returns a list of (optionally filtered) cooperative asset transactions. + description: Returns the list of (optionally filtered) cooperative asset transactions which are visible to the current user or any of it's assumed roles. + tags: + - hs-office-coopAssets + operationId: listCoopAssets + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: membershipUuid + in: query + required: false + schema: + type: string + format: uuid + description: Optional UUID of the related membership. + - name: fromValueDate + in: query + required: false + schema: + type: string + format: date + description: Optional value date range start (inclusive). + - name: toValueDate + in: query + required: false + schema: + type: string + format: date + description: Optional value date range end (inclusive). + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: './hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransaction' + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new cooperative asset transaction. + tags: + - hs-office-coopAssets + operationId: addCoopAssetsTransaction + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + requestBody: + description: A JSON object describing the new cooperative assets transaction. + required: true + content: + application/json: + schema: + $ref: '/hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransactionInsert' + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: './hs-office-coopassets-schemas.yaml#/components/schemas/HsOfficeCoopAssetsTransaction' + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: './error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office.yaml b/src/main/resources/api-definition/hs-office/hs-office.yaml index efcf61cd..15014baa 100644 --- a/src/main/resources/api-definition/hs-office/hs-office.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office.yaml @@ -84,3 +84,9 @@ paths: /api/hs/office/coopsharestransactions: $ref: "./hs-office-coopshares.yaml" + + + # Coop Assets Transaction + + /api/hs/office/coopassetstransactions: + $ref: "./hs-office-coopassets.yaml" diff --git a/src/main/resources/db/changelog/320-hs-office-coopassets.sql b/src/main/resources/db/changelog/320-hs-office-coopassets.sql new file mode 100644 index 00000000..db76d95b --- /dev/null +++ b/src/main/resources/db/changelog/320-hs-office-coopassets.sql @@ -0,0 +1,62 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-office-coopassets-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +CREATE TYPE HsOfficeCoopAssetsTransactionType AS ENUM ('ADJUSTMENT', + 'DEPOSIT', + 'DISBURSAL', + 'TRANSFER', + 'ADOPTION', + 'CLEARING', + 'LOSS'); + +CREATE CAST (character varying as HsOfficeCoopAssetsTransactionType) WITH INOUT AS IMPLICIT; + +create table if not exists hs_office_coopassetstransaction +( + uuid uuid unique references RbacObject (uuid) initially deferred, + membershipUuid uuid not null references hs_office_membership(uuid), + transactionType HsOfficeCoopAssetsTransactionType not null, + valueDate date not null, + assetValue money, + reference varchar(48), + comment varchar(512) +); +--// + +-- ============================================================================ +--changeset hs-office-coopassets-ASSET-VALUE-CONSTRAINT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create or replace function checkAssetsByMembershipUuid(forMembershipUuid UUID, newAssetValue money) +returns boolean +language plpgsql as $$ +declare + currentAssetValue money; + totalAssetValue money; +begin + select sum(cat.assetValue) + from hs_office_coopassetstransaction cat + where cat.membershipUuid = forMembershipUuid + into currentAssetValue; + totalAssetValue := currentAssetValue + newAssetValue; + if totalAssetValue::numeric < 0 then + raise exception '[400] coop assets transaction would result in a negative balance of assets'; + end if; + return true; +end; $$; + +alter table hs_office_coopassetstransaction + add constraint hs_office_coopassets_positive + check ( checkAssetsByMembershipUuid(membershipUuid, assetValue) ); + +--// + +-- ============================================================================ +--changeset hs-office-coopassets-MAIN-TABLE-JOURNAL:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call create_journal('hs_office_coopassetstransaction'); +--// diff --git a/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.md b/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.md new file mode 100644 index 00000000..94ce746a --- /dev/null +++ b/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.md @@ -0,0 +1,29 @@ +### hs_office_coopAssetsTransaction RBAC + +```mermaid +flowchart TB + +subgraph hsOfficeMembership + direction TB + style hsOfficeMembership fill:#eee + + role:hsOfficeMembership.owner[membership.admin] + --> role:hsOfficeMembership.admin[membership.admin] + --> role:hsOfficeMembership.agent[membership.agent] + --> role:hsOfficeMembership.tenant[membership.tenant] + --> role:hsOfficeMembership.guest[membership.guest] + + role:hsOfficePartner.agent --> role:hsOfficeMembership.agent +end + +subgraph hsOfficeCoopAssetsTransaction + + role:hsOfficeMembership.admin + --> perm:hsOfficeCoopAssetsTransaction.create{{coopAssetsTx.create}} + + role:hsOfficeMembership.agent + --> perm:hsOfficeCoopAssetsTransaction.view{{coopAssetsTx.view}} +end + + +``` diff --git a/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.sql b/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.sql new file mode 100644 index 00000000..6589eaa2 --- /dev/null +++ b/src/main/resources/db/changelog/323-hs-office-coopassets-rbac.sql @@ -0,0 +1,124 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-office-coopAssetsTransaction-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_coopAssetsTransaction'); +--// + + +-- ============================================================================ +--changeset hs-office-coopAssetsTransaction-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeCoopAssetsTransaction', 'hs_office_coopAssetsTransaction'); +--// + + +-- ============================================================================ +--changeset hs-office-coopAssetsTransaction-rbac-ROLES-CREATION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates and updates the permissions for coopAssetsTransaction entities. + */ + +create or replace function hsOfficeCoopAssetsTransactionRbacRolesTrigger() + returns trigger + language plpgsql + strict as $$ +declare + newHsOfficeMembership hs_office_membership; +begin + + select * from hs_office_membership as p where p.uuid = NEW.membershipUuid into newHsOfficeMembership; + + if TG_OP = 'INSERT' then + + -- Each coopAssetsTransaction entity belong exactly to one membership entity + -- and it makes little sense just to delegate coopAssetsTransaction roles. + -- Therefore, we do not create coopAssetsTransaction roles at all, + -- but instead just assign extra permissions to existing membership-roles. + + -- coopassetstransactions cannot be edited nor deleted, just created+viewed + call grantPermissionsToRole( + getRoleId(hsOfficeMembershipTenant(newHsOfficeMembership), 'fail'), + createPermissions(NEW.uuid, array ['view']) + ); + + else + raise exception 'invalid usage of TRIGGER'; + end if; + + return NEW; +end; $$; + +/* + An AFTER INSERT TRIGGER which creates the role structure for a new customer. + */ +create trigger createRbacRolesForHsOfficeCoopAssetsTransaction_Trigger + after insert + on hs_office_coopAssetsTransaction + for each row +execute procedure hsOfficeCoopAssetsTransactionRbacRolesTrigger(); +--// + + +-- ============================================================================ +--changeset hs-office-coopAssetsTransaction-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacIdentityView('hs_office_coopAssetsTransaction', + idNameExpression => 'target.reference'); +--// + + +-- ============================================================================ +--changeset hs-office-coopAssetsTransaction-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_coopAssetsTransaction', orderby => 'target.reference'); +--// + + +-- ============================================================================ +--changeset hs-office-coopAssetsTransaction-rbac-NEW-CoopAssetsTransaction:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + Creates a global permission for new-coopAssetsTransaction and assigns it to the hostsharing admins role. + */ +do language plpgsql $$ + declare + addCustomerPermissions uuid[]; + globalObjectUuid uuid; + globalAdminRoleUuid uuid ; + begin + call defineContext('granting global new-coopAssetsTransaction permission to global admin role', null, null, null); + + globalAdminRoleUuid := findRoleId(globalAdmin()); + globalObjectUuid := (select uuid from global); + addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-coopassetstransaction']); + call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions); + end; +$$; + +/** + Used by the trigger to prevent the add-customer to current user respectively assumed roles. + */ +create or replace function addHsOfficeCoopAssetsTransactionNotAllowedForCurrentSubjects() + returns trigger + language PLPGSQL +as $$ +begin + raise exception '[403] new-coopassetstransaction not permitted for %', + array_to_string(currentSubjects(), ';', 'null'); +end; $$; + +/** + Checks if the user or assumed roles are allowed to create a new customer. + */ +create trigger hs_office_coopAssetsTransaction_insert_trigger + before insert + on hs_office_coopAssetsTransaction + for each row + when ( not hasAssumedRole() ) +execute procedure addHsOfficeCoopAssetsTransactionNotAllowedForCurrentSubjects(); +--// + diff --git a/src/main/resources/db/changelog/328-hs-office-coopassets-test-data.sql b/src/main/resources/db/changelog/328-hs-office-coopassets-test-data.sql new file mode 100644 index 00000000..8259a7e3 --- /dev/null +++ b/src/main/resources/db/changelog/328-hs-office-coopassets-test-data.sql @@ -0,0 +1,44 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-office-coopAssetsTransaction-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single coopAssetsTransaction test record. + */ +create or replace procedure createHsOfficeCoopAssetsTransactionTestData(givenMembershipNumber numeric) + language plpgsql as $$ +declare + currentTask varchar; + membership hs_office_membership; +begin + currentTask = 'creating coopAssetsTransaction test-data ' || givenMembershipNumber; + execute format('set local hsadminng.currentTask to %L', currentTask); + + call defineContext(currentTask); + select m.uuid from hs_office_membership m where m.memberNumber = givenMembershipNumber into membership; + + raise notice 'creating test coopAssetsTransaction: %', givenMembershipNumber; + insert + into hs_office_coopassetstransaction(uuid, membershipuuid, transactiontype, valuedate, assetvalue, reference, comment) + values + (uuid_generate_v4(), membership.uuid, 'DEPOSIT', '2010-03-15', 320.00, 'ref '||givenMembershipNumber||'-1', 'initial deposit'), + (uuid_generate_v4(), membership.uuid, 'DISBURSAL', '2021-09-01', -128.00, 'ref '||givenMembershipNumber||'-2', 'partial disbursal'), + (uuid_generate_v4(), membership.uuid, 'ADJUSTMENT', '2022-10-20', 128.00, 'ref '||givenMembershipNumber||'-3', 'some adjustment'); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-office-coopAssetsTransaction-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call createHsOfficeCoopAssetsTransactionTestData(10001); + call createHsOfficeCoopAssetsTransactionTestData(10002); + call createHsOfficeCoopAssetsTransactionTestData(10003); + end; +$$; diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 7905881e..3a1bb533 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -105,3 +105,9 @@ databaseChangeLog: file: db/changelog/313-hs-office-coopshares-rbac.sql - include: file: db/changelog/318-hs-office-coopshares-test-data.sql + - include: + file: db/changelog/320-hs-office-coopassets.sql + - include: + file: db/changelog/323-hs-office-coopassets-rbac.sql + - include: + file: db/changelog/328-hs-office-coopassets-test-data.sql diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java index e0c5274c..ff3a9eaa 100644 --- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java +++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java @@ -36,6 +36,7 @@ public class ArchitectureTest { "..hs.office.relationship", "..hs.office.contact", "..hs.office.sepamandate", + "..hs.office.coopassets", "..hs.office.coopshares", "..hs.office.membership", "..errors", @@ -156,7 +157,14 @@ public class ArchitectureTest { public static final ArchRule hsOfficeMembershipPackageRule = classes() .that().resideInAPackage("..hs.office.membership..") .should().onlyBeAccessed().byClassesThat() - .resideInAnyPackage("..hs.office.membership..", "..hs.office.coopshares.."); + .resideInAnyPackage("..hs.office.membership..", "..hs.office.coopassets..", "..hs.office.coopshares.."); + + @ArchTest + @SuppressWarnings("unused") + public static final ArchRule hsOfficeCoopAssetsPackageRule = classes() + .that().resideInAPackage("..hs.office.coopassets..") + .should().onlyBeAccessed().byClassesThat() + .resideInAnyPackage("..hs.office.coopassets.."); @ArchTest @SuppressWarnings("unused") 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 new file mode 100644 index 00000000..93b0eb7c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java @@ -0,0 +1,242 @@ +package net.hostsharing.hsadminng.hs.office.coopassets; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionRepository; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipRepository; +import net.hostsharing.test.Accepts; +import net.hostsharing.test.JpaAttempt; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityManager; +import java.util.UUID; + +import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.startsWith; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, JpaAttempt.class } +) +@Transactional +class HsOfficeCoopAssetsTransactionControllerAcceptanceTest { + + @LocalServerPort + private Integer port; + + @Autowired + Context context; + + @Autowired + HsOfficeMembershipRepository membershipRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @Autowired + EntityManager em; + + @Nested + @Accepts({ "CoopAssetsTransaction:F(Find)" }) + class ListCoopAssetsTransactions { + + @Test + void globalAdmin_canViewAllCoopAssetsTransactions() { + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/coopassetstransactions") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", hasSize(9)); // @formatter:on + } + + @Test + void globalAdmin_canFindCoopAssetsTransactionsByMemberNumber() { + + context.define("superuser-alex@hostsharing.net"); + final var givenMembership = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, 10002) + .get(0); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/coopassetstransactions?membershipUuid="+givenMembership.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "transactionType": "DEPOSIT", + "assetValue": 320.00, + "valueDate": "2010-03-15", + "reference": "ref 10002-1", + "comment": "initial deposit" + }, + { + "transactionType": "DISBURSAL", + "assetValue": -128.00, + "valueDate": "2021-09-01", + "reference": "ref 10002-2", + "comment": "partial disbursal" + }, + { + "transactionType": "ADJUSTMENT", + "assetValue": 128.00, + "valueDate": "2022-10-20", + "reference": "ref 10002-3", + "comment": "some adjustment" + } + ] + """)); // @formatter:on + } + + @Test + void globalAdmin_canFindCoopAssetsTransactionsByMemberNumberAndDateRange() { + + context.define("superuser-alex@hostsharing.net"); + final var givenMembership = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, 10002) + .get(0); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/coopassetstransactions?membershipUuid=" + + givenMembership.getUuid() + "&fromValueDate=2020-01-01&toValueDate=2021-12-31") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "transactionType": "DISBURSAL", + "assetValue": -128.00, + "valueDate": "2021-09-01", + "reference": "ref 10002-2", + "comment": "partial disbursal" + } + ] + """)); // @formatter:on + } + } + + @Nested + @Accepts({ "CoopAssetsTransaction:C(Create)" }) + class AddCoopAssetsTransaction { + + @Test + void globalAdmin_canAddCoopAssetsTransaction() { + + context.define("superuser-alex@hostsharing.net"); + final var givenMembership = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, 10001) + .get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "membershipUuid": "%s", + "transactionType": "DEPOSIT", + "assetValue": 1024.00, + "valueDate": "2022-10-13", + "reference": "temp ref A", + "comment": "just some test coop assets transaction" + } + """.formatted(givenMembership.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/office/coopassetstransactions") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("", lenientlyEquals(""" + { + "transactionType": "DEPOSIT", + "assetValue": 1024.00, + "valueDate": "2022-10-13", + "reference": "temp ref A", + "comment": "just some test coop assets transaction" + } + """)) + .header("Location", startsWith("http://localhost")) + .extract().header("Location"); // @formatter:on + + // finally, the new coopAssetsTransaction can be accessed under the generated UUID + final var newUserUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newUserUuid).isNotNull(); + } + + @Test + void globalAdmin_canNotCancelMoreAssetsThanCurrentlySubscribed() { + + context.define("superuser-alex@hostsharing.net"); + final var givenMembership = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, 10001) + .get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "membershipUuid": "%s", + "transactionType": "DISBURSAL", + "assetValue": -10240.00, + "valueDate": "2022-10-13", + "reference": "temp ref X", + "comment": "just some test coop assets transaction" + } + """.formatted(givenMembership.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/office/coopassetstransactions") + .then().log().all().assertThat() + .statusCode(400) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "status": 400, + "error": "Bad Request", + "message": "ERROR: [400] coop assets transaction would result in a negative balance of assets" + } + """)); // @formatter:on + } + } + + @BeforeEach + @AfterEach + void cleanup() { + jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + // HsOfficeCoopAssetsTransactionEntity respectively hs_office_coopassetstransaction_rv + // cannot be deleted at all, but the underlying table record can be deleted. + em.createNativeQuery("delete from hs_office_coopassetstransaction where reference like 'temp %'") + .executeUpdate(); + }).assertSuccessful(); + } +} 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 new file mode 100644 index 00000000..c031cc12 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java @@ -0,0 +1,126 @@ +package net.hostsharing.hsadminng.hs.office.coopassets; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.test.JsonBuilder; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.util.UUID; +import java.util.function.Function; + +import static net.hostsharing.test.JsonBuilder.jsonObject; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(HsOfficeCoopAssetsTransactionController.class) +class HsOfficeCoopAssetsTransactionControllerRestTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + Context contextMock; + + @MockBean + HsOfficeCoopAssetsTransactionRepository coopAssetsTransactionRepo; + + static final String VALID_INSERT_REQUEST_BODY = """ + { + "membershipUuid": "%s", + "transactionType": "DEPOSIT", + "assetValue": 128.00, + "valueDate": "2022-10-13", + "reference": "valid reference", + "comment": "valid comment" + } + """.formatted(UUID.randomUUID()); + + enum BadRequestTestCases { + MEMBERSHIP_UUID_MISSING( + requestBody -> requestBody.without("membershipUuid"), + "[membershipUuid must not be null but is \"null\"]"), + + TRANSACTION_TYPE_MISSING( + requestBody -> requestBody.without("transactionType"), + "[transactionType must not be null but is \"null\"]"), + + VALUE_DATE_MISSING( + requestBody -> requestBody.without("valueDate"), + "[valueDate must not be null but is \"null\"]"), + + ASSETS_VALUE_FOR_DEPOSIT_MUST_BE_POSITIVE( + requestBody -> requestBody + .with("transactionType", "DEPOSIT") + .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") + .with("assetValue", 64.00), + "[for DISBURSAL, assetValue must be negative but is \"64.00\"]"), + + //TODO: other transaction types + + ASSETS_VALUE_MUST_NOT_BE_NULL( + requestBody -> requestBody + .with("transactionType", "ADJUSTMENT") + .with("assetValue", 0.00), + "[assetValue must not be 0 but is \"0.00\"]"), + + REFERENCE_MISSING( + requestBody -> requestBody.without("reference"), + "[reference must not be null but is \"null\"]"), + + REFERENCE_TOO_SHORT( + requestBody -> requestBody.with("reference", "12345"), + "[reference size must be between 6 and 48 but is \"12345\"]"), + + REFERENCE_TOO_LONG( + requestBody -> requestBody.with("reference", "0123456789012345678901234567890123456789012345678"), + "[reference size must be between 6 and 48 but is \"0123456789012345678901234567890123456789012345678\"]"); + + private final Function givenBodyTransformation; + private final String expectedErrorMessage; + + BadRequestTestCases( + final Function givenBodyTransformation, + final String expectedErrorMessage) { + this.givenBodyTransformation = givenBodyTransformation; + this.expectedErrorMessage = expectedErrorMessage; + } + + String givenRequestBody() { + return givenBodyTransformation.apply(jsonObject(VALID_INSERT_REQUEST_BODY)).toString(); + } + } + + @ParameterizedTest + @EnumSource(BadRequestTestCases.class) + void respondWithBadRequest(final BadRequestTestCases testCase) throws Exception { + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/office/coopassetstransactions") + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(testCase.givenRequestBody()) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("status", is(400))) + .andExpect(jsonPath("error", is("Bad Request"))) + .andExpect(jsonPath("message", is(testCase.expectedErrorMessage))); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityTest.java new file mode 100644 index 00000000..391fd193 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityTest.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.hs.office.coopassets; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static net.hostsharing.hsadminng.hs.office.membership.TestHsMembership.testMembership; +import static org.assertj.core.api.Assertions.assertThat; + +class HsOfficeCoopAssetsTransactionEntityTest { + + final HsOfficeCoopAssetsTransactionEntity givenCoopAssetTransaction = HsOfficeCoopAssetsTransactionEntity.builder() + .membership(testMembership) + .reference("some-ref") + .valueDate(LocalDate.parse("2020-01-01")) + .transactionType(HsOfficeCoopAssetsTransactionType.DEPOSIT) + .assetValue(new BigDecimal("128.00")) + .build(); + + @Test + void toStringContainsAlmostAllPropertiesAccount() { + final var result = givenCoopAssetTransaction.toString(); + + assertThat(result).isEqualTo("CoopAssetsTransaction(300001, 2020-01-01, DEPOSIT, 128.00, some-ref)"); + } + + @Test + void toShortStringContainsOnlyMemberNumberAndSharesCountOnly() { + final var result = givenCoopAssetTransaction.toShortString(); + + assertThat(result).isEqualTo("300001+128.00"); + } +} 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 new file mode 100644 index 00000000..5ea0714a --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java @@ -0,0 +1,269 @@ +package net.hostsharing.hsadminng.hs.office.coopassets; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.test.Array; +import net.hostsharing.test.JpaAttempt; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityManager; +import javax.servlet.http.HttpServletRequest; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; +import static net.hostsharing.test.JpaAttempt.attempt; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ComponentScan(basePackageClasses = { HsOfficeCoopAssetsTransactionRepository.class, Context.class, JpaAttempt.class }) +@DirtiesContext +class HsOfficeCoopAssetsTransactionRepositoryIntegrationTest extends ContextBasedTest { + + @Autowired + HsOfficeCoopAssetsTransactionRepository coopAssetsTransactionRepo; + + @Autowired + HsOfficeMembershipRepository membershipRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @Autowired + EntityManager em; + + @Autowired + JpaAttempt jpaAttempt; + + @MockBean + HttpServletRequest request; + + @Nested + class CreateCoopAssetsTransaction { + + @Test + public void globalAdmin_canCreateNewCoopAssetTransaction() { + // given + context("superuser-alex@hostsharing.net"); + final var count = coopAssetsTransactionRepo.count(); + final var givenMembership = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, 10001) + .get(0); + + // when + final var result = attempt(em, () -> { + final var newCoopAssetsTransaction = HsOfficeCoopAssetsTransactionEntity.builder() + .uuid(UUID.randomUUID()) + .membership(givenMembership) + .transactionType(HsOfficeCoopAssetsTransactionType.DEPOSIT) + .assetValue(new BigDecimal("128.00")) + .valueDate(LocalDate.parse("2022-10-18")) + .reference("temp ref A") + .build(); + return coopAssetsTransactionRepo.save(newCoopAssetsTransaction); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeCoopAssetsTransactionEntity::getUuid).isNotNull(); + assertThatCoopAssetsTransactionIsPersisted(result.returnedValue()); + assertThat(coopAssetsTransactionRepo.count()).isEqualTo(count + 1); + } + + @Test + public void createsAndGrantsRoles() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()).stream() + .map(s -> s.replace("FirstGmbH-firstcontact", "...")) + .map(s -> s.replace("hs_office_", "")) + .toList(); + + // when + attempt(em, () -> { + final var givenMembership = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber( + null, + 10001).get(0); + final var newCoopAssetsTransaction = HsOfficeCoopAssetsTransactionEntity.builder() + .uuid(UUID.randomUUID()) + .membership(givenMembership) + .transactionType(HsOfficeCoopAssetsTransactionType.DEPOSIT) + .assetValue(new BigDecimal("128.00")) + .valueDate(LocalDate.parse("2022-10-18")) + .reference("temp ref B") + .build(); + return coopAssetsTransactionRepo.save(newCoopAssetsTransaction); + }); + + // then + final var all = rawRoleRepo.findAll(); + assertThat(roleNamesOf(all)).containsExactlyInAnyOrder(Array.from(initialRoleNames)); // no new roles created + assertThat(grantDisplaysOf(rawGrantRepo.findAll())) + .map(s -> s.replace("FirstGmbH-firstcontact", "...")) + .map(s -> s.replace("hs_office_", "")) + .containsExactlyInAnyOrder(Array.fromFormatted( + initialGrantNames, + "{ grant perm view on coopassetstransaction#temprefB to role membership#10001....tenant by system and assume }", + null)); + } + + private void assertThatCoopAssetsTransactionIsPersisted(final HsOfficeCoopAssetsTransactionEntity saved) { + final var found = coopAssetsTransactionRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + } + } + + @Nested + class FindAllCoopAssetsTransactions { + + @Test + public void globalAdmin_anViewAllCoopAssetsTransactions() { + // given + context("superuser-alex@hostsharing.net"); + + // when + final var result = coopAssetsTransactionRepo.findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( + null, + null, + null); + + // then + allTheseCoopAssetsTransactionsAreReturned( + result, + "CoopAssetsTransaction(10001, 2010-03-15, DEPOSIT, 320.00, ref 10001-1)", + "CoopAssetsTransaction(10001, 2021-09-01, DISBURSAL, -128.00, ref 10001-2)", + "CoopAssetsTransaction(10001, 2022-10-20, ADJUSTMENT, 128.00, ref 10001-3)", + + "CoopAssetsTransaction(10002, 2010-03-15, DEPOSIT, 320.00, ref 10002-1)", + "CoopAssetsTransaction(10002, 2021-09-01, DISBURSAL, -128.00, ref 10002-2)", + "CoopAssetsTransaction(10002, 2022-10-20, ADJUSTMENT, 128.00, ref 10002-3)", + + "CoopAssetsTransaction(10003, 2010-03-15, DEPOSIT, 320.00, ref 10003-1)", + "CoopAssetsTransaction(10003, 2021-09-01, DISBURSAL, -128.00, ref 10003-2)", + "CoopAssetsTransaction(10003, 2022-10-20, ADJUSTMENT, 128.00, ref 10003-3)"); + } + + @Test + public void globalAdmin_canViewCoopAssetsTransactions_filteredByMembershipUuid() { + // given + context("superuser-alex@hostsharing.net"); + final var givenMembership = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, 10002) + .get(0); + + // when + final var result = coopAssetsTransactionRepo.findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( + givenMembership.getUuid(), + null, + null); + + // then + allTheseCoopAssetsTransactionsAreReturned( + result, + "CoopAssetsTransaction(10002, 2010-03-15, DEPOSIT, 320.00, ref 10002-1)", + "CoopAssetsTransaction(10002, 2021-09-01, DISBURSAL, -128.00, ref 10002-2)", + "CoopAssetsTransaction(10002, 2022-10-20, ADJUSTMENT, 128.00, ref 10002-3)"); + } + + @Test + public void globalAdmin_canViewCoopAssetsTransactions_filteredByMembershipUuidAndValueDateRange() { + // given + context("superuser-alex@hostsharing.net"); + final var givenMembership = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, 10002) + .get(0); + + // when + final var result = coopAssetsTransactionRepo.findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( + givenMembership.getUuid(), + LocalDate.parse("2021-09-01"), + LocalDate.parse("2021-09-01")); + + // then + allTheseCoopAssetsTransactionsAreReturned( + result, + "CoopAssetsTransaction(10002, 2021-09-01, DISBURSAL, -128.00, ref 10002-2)"); + } + + @Test + public void normalUser_canViewOnlyRelatedCoopAssetsTransactions() { + // given: + context("superuser-alex@hostsharing.net", "hs_office_partner#FirstGmbH-firstcontact.admin"); + // "hs_office_person#FirstGmbH.admin", + + // when: + final var result = coopAssetsTransactionRepo.findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange( + null, + null, + null); + + // then: + exactlyTheseCoopAssetsTransactionsAreReturned( + result, + "CoopAssetsTransaction(10001, 2010-03-15, DEPOSIT, 320.00, ref 10001-1)", + "CoopAssetsTransaction(10001, 2021-09-01, DISBURSAL, -128.00, ref 10001-2)", + "CoopAssetsTransaction(10001, 2022-10-20, ADJUSTMENT, 128.00, ref 10001-3)"); + } + } + + @Test + public void auditJournalLogIsAvailable() { + // given + final var query = em.createNativeQuery(""" + select c.currenttask, j.targettable, j.targetop + from tx_journal j + join tx_context c on j.contextId = c.contextId + where targettable = 'hs_office_coopassetstransaction'; + """); + + // when + @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); + + // then + assertThat(customerLogEntries).map(Arrays::toString).contains( + "[creating coopAssetsTransaction test-data 10001, hs_office_coopassetstransaction, INSERT]", + "[creating coopAssetsTransaction test-data 10002, hs_office_coopassetstransaction, INSERT]"); + } + + @BeforeEach + @AfterEach + void cleanup() { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", null); + em.createQuery("DELETE FROM HsOfficeCoopAssetsTransactionEntity WHERE reference like 'temp ref%'"); + }); + } + + void exactlyTheseCoopAssetsTransactionsAreReturned( + final List actualResult, + final String... coopAssetsTransactionNames) { + assertThat(actualResult) + .extracting(coopAssetsTransactionEntity -> coopAssetsTransactionEntity.toString()) + .containsExactlyInAnyOrder(coopAssetsTransactionNames); + } + + void allTheseCoopAssetsTransactionsAreReturned( + final List actualResult, + final String... coopAssetsTransactionNames) { + assertThat(actualResult) + .extracting(coopAssetsTransactionEntity -> coopAssetsTransactionEntity.toString()) + .contains(coopAssetsTransactionNames); + } +}