Michael Hoennig
2022-10-21 a39cf73cf0bfbe05942a8b59d1f59554fe1d15c2
hs-office-coopassets, no get API endpoints yet
14 files added
4 files modified
1315 ■■■■■ changed files
src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java 118 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java 75 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepository.java 29 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionType.java 5 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/api-mappings.yaml 1 ●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml 63 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml 72 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office.yaml 6 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/320-hs-office-coopassets.sql 62 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/323-hs-office-coopassets-rbac.md 29 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/323-hs-office-coopassets-rbac.sql 124 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/328-hs-office-coopassets-test-data.sql 44 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/db.changelog-master.yaml 6 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java 10 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java 242 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java 126 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityTest.java 34 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java 269 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java
New file
@@ -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<List<HsOfficeCoopAssetsTransactionResource>> 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<HsOfficeCoopAssetsTransactionResource> 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<String>();
        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<String> 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<String> 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<String> violations) {
        if (requestBody.getAssetValue().signum() == 0) {
            violations.add("assetValue must not be 0 but is \"%.2f\"".formatted(
                requestBody.getAssetValue()));
        }
    }
}
src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java
New file
@@ -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<HsOfficeCoopAssetsTransactionEntity> 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);
    }
}
src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepository.java
New file
@@ -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<HsOfficeCoopAssetsTransactionEntity, UUID> {
    Optional<HsOfficeCoopAssetsTransactionEntity> 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<HsOfficeCoopAssetsTransactionEntity> findCoopAssetsTransactionByOptionalMembershipUuidAndDateRange(
            UUID membershipUuid, LocalDate fromValueDate, LocalDate toValueDate);
    HsOfficeCoopAssetsTransactionEntity save(final HsOfficeCoopAssetsTransactionEntity entity);
    long count();
}
src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionType.java
New file
@@ -0,0 +1,5 @@
package net.hostsharing.hsadminng.hs.office.coopassets;
public enum HsOfficeCoopAssetsTransactionType {
    ADJUSTMENT, DEPOSIT, DISBURSAL, TRANSFER, ADOPTION, CLEARING, LOSS
}
src/main/resources/api-definition/hs-office/api-mappings.yaml
@@ -12,6 +12,7 @@
        - 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}:
src/main/resources/api-definition/hs-office/hs-office-coopassets-schemas.yaml
New file
@@ -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
src/main/resources/api-definition/hs-office/hs-office-coopassets.yaml
New file
@@ -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'
src/main/resources/api-definition/hs-office/hs-office.yaml
@@ -84,3 +84,9 @@
  /api/hs/office/coopsharestransactions:
    $ref: "./hs-office-coopshares.yaml"
    # Coop Assets Transaction
  /api/hs/office/coopassetstransactions:
      $ref: "./hs-office-coopassets.yaml"
src/main/resources/db/changelog/320-hs-office-coopassets.sql
New file
@@ -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');
--//
src/main/resources/db/changelog/323-hs-office-coopassets-rbac.md
New file
@@ -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
```
src/main/resources/db/changelog/323-hs-office-coopassets-rbac.sql
New file
@@ -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();
--//
src/main/resources/db/changelog/328-hs-office-coopassets-test-data.sql
New file
@@ -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;
$$;
src/main/resources/db/changelog/db.changelog-master.yaml
@@ -105,3 +105,9 @@
        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
src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
@@ -36,6 +36,7 @@
                    "..hs.office.relationship",
                    "..hs.office.contact",
                    "..hs.office.sepamandate",
                    "..hs.office.coopassets",
                    "..hs.office.coopshares",
                    "..hs.office.membership",
                    "..errors",
@@ -156,7 +157,14 @@
    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")
src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerAcceptanceTest.java
New file
@@ -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();
    }
}
src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionControllerRestTest.java
New file
@@ -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<JsonBuilder, JsonBuilder> givenBodyTransformation;
        private final String expectedErrorMessage;
        BadRequestTestCases(
                final Function<JsonBuilder, JsonBuilder> 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)));
    }
}
src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntityTest.java
New file
@@ -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");
    }
}
src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java
New file
@@ -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<Object[]> 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<HsOfficeCoopAssetsTransactionEntity> actualResult,
            final String... coopAssetsTransactionNames) {
        assertThat(actualResult)
                .extracting(coopAssetsTransactionEntity -> coopAssetsTransactionEntity.toString())
                .containsExactlyInAnyOrder(coopAssetsTransactionNames);
    }
    void allTheseCoopAssetsTransactionsAreReturned(
            final List<HsOfficeCoopAssetsTransactionEntity> actualResult,
            final String... coopAssetsTransactionNames) {
        assertThat(actualResult)
                .extracting(coopAssetsTransactionEntity -> coopAssetsTransactionEntity.toString())
                .contains(coopAssetsTransactionNames);
    }
}