Michael Hoennig
2022-10-15 e305c3c935077d2e7409402c7a9a64aa9f14bd49
add SEPA-mandate API+Controller
5 files added
6 files modified
960 ■■■■■ changed files
src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java 3 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java 151 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepository.java 3 ●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/api-mappings.yaml 4 ●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-sepamandate-schemas.yaml 60 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-sepamandates-with-uuid.yaml 83 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-sepamandates.yaml 57 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office.yaml 9 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java 71 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java 511 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java 8 ●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java
@@ -15,9 +15,12 @@
            SELECT c FROM HsOfficeBankAccountEntity c
                WHERE :holder is null
                    OR lower(c.holder) like lower(concat(:holder, '%'))
                ORDER BY c.holder
               """)
    List<HsOfficeBankAccountEntity> findByOptionalHolderLike(String holder);
    List<HsOfficeBankAccountEntity> findByIbanOrderByIban(String iban);
    HsOfficeBankAccountEntity save(final HsOfficeBankAccountEntity entity);
    int deleteByUuid(final UUID uuid);
src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java
New file
@@ -0,0 +1,151 @@
package net.hostsharing.hsadminng.hs.office.sepamandate;
import com.vladmihalcea.hibernate.type.range.Range;
import net.hostsharing.hsadminng.Mapper;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeSepaMandatesApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*;
import org.springframework.beans.factory.annotation.Autowired;
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.persistence.EntityManager;
import javax.validation.Valid;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.Mapper.map;
@RestController
public class HsOfficeSepaMandateController implements HsOfficeSepaMandatesApi {
    @Autowired
    private Context context;
    @Autowired
    private HsOfficeSepaMandateRepository SepaMandateRepo;
    @Autowired
    private EntityManager em;
    @Override
    @Transactional(readOnly = true)
    public ResponseEntity<List<HsOfficeSepaMandateResource>> listSepaMandatesByIban(
            final String currentUser,
            final String assumedRoles,
            final String iban) {
        context.define(currentUser, assumedRoles);
        final var entities = SepaMandateRepo.findSepaMandateByOptionalIban(iban);
        final var resources = Mapper.mapList(entities, HsOfficeSepaMandateResource.class,
                SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER);
        return ResponseEntity.ok(resources);
    }
    @Override
    @Transactional
    public ResponseEntity<HsOfficeSepaMandateResource> addSepaMandate(
            final String currentUser,
            final String assumedRoles,
            @Valid final HsOfficeSepaMandateInsertResource body) {
        context.define(currentUser, assumedRoles);
        final var entityToSave = map(body, HsOfficeSepaMandateEntity.class, SEPA_MANDATE_RESOURCE_TO_ENTITY_POSTMAPPER);
        entityToSave.setUuid(UUID.randomUUID());
        final var saved = SepaMandateRepo.save(entityToSave);
        final var uri =
                MvcUriComponentsBuilder.fromController(getClass())
                        .path("/api/hs/office/SepaMandates/{id}")
                        .buildAndExpand(entityToSave.getUuid())
                        .toUri();
        final var mapped = map(saved, HsOfficeSepaMandateResource.class,
                SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER);
        return ResponseEntity.created(uri).body(mapped);
    }
    @Override
    @Transactional(readOnly = true)
    public ResponseEntity<HsOfficeSepaMandateResource> getSepaMandateByUuid(
            final String currentUser,
            final String assumedRoles,
            final UUID sepaMandateUuid) {
        context.define(currentUser, assumedRoles);
        final var result = SepaMandateRepo.findByUuid(sepaMandateUuid);
        if (result.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(map(result.get(), HsOfficeSepaMandateResource.class,
                SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER));
    }
    @Override
    @Transactional
    public ResponseEntity<Void> deleteSepaMandateByUuid(
            final String currentUser,
            final String assumedRoles,
            final UUID sepaMandateUuid) {
        context.define(currentUser, assumedRoles);
        final var result = SepaMandateRepo.deleteByUuid(sepaMandateUuid);
        if (result == 0) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.noContent().build();
    }
    @Override
    @Transactional
    public ResponseEntity<HsOfficeSepaMandateResource> patchSepaMandate(
            final String currentUser,
            final String assumedRoles,
            final UUID sepaMandateUuid,
            final HsOfficeSepaMandatePatchResource body) {
        context.define(currentUser, assumedRoles);
        final var current = SepaMandateRepo.findByUuid(sepaMandateUuid).orElseThrow();
        current.setValidity(toPostgresDateRange(current.getValidity().lower(), body.getValidTo()));
        final var saved = SepaMandateRepo.save(current);
        final var mapped = map(saved, HsOfficeSepaMandateResource.class, SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER);
        return ResponseEntity.ok(mapped);
    }
    private static Range<LocalDate> toPostgresDateRange(
            final LocalDate validFrom,
            final LocalDate validTo) {
        return validTo != null
                ? Range.closedOpen(validFrom, validTo.plusDays(1))
                : Range.closedInfinite(validFrom);
    }
    final BiConsumer<HsOfficeSepaMandateEntity, HsOfficeSepaMandateResource> SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
        resource.setDebitor(map(entity.getDebitor(), HsOfficeDebitorResource.class));
        resource.setBankAccount(map(entity.getBankAccount(), HsOfficeBankAccountResource.class));
        resource.setValidFrom(entity.getValidity().lower());
        if (entity.getValidity().hasUpperBound()) {
            resource.setValidTo(entity.getValidity().upper().minusDays(1));
        }
    };
    final BiConsumer<HsOfficeSepaMandateInsertResource, HsOfficeSepaMandateEntity> SEPA_MANDATE_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
        entity.setDebitor(em.getReference(HsOfficeDebitorEntity.class, resource.getDebitorUuid()));
        entity.setBankAccount(em.getReference(HsOfficeBankAccountEntity.class, resource.getBankAccountUuid()));
        entity.setValidity(toPostgresDateRange(resource.getValidFrom(), resource.getValidTo()));
    };
}
src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepository.java
@@ -15,8 +15,9 @@
            SELECT mandate FROM HsOfficeSepaMandateEntity mandate
                WHERE :iban is null
                    OR mandate.bankAccount.iban like concat(:iban, '%')
                ORDER BY mandate.bankAccount.iban
               """)
    List<HsOfficeSepaMandateEntity> findSepaMandateByOptionalIBAN(String iban);
    List<HsOfficeSepaMandateEntity> findSepaMandateByOptionalIban(String iban);
    HsOfficeSepaMandateEntity save(final HsOfficeSepaMandateEntity entity);
src/main/resources/api-definition/hs-office/api-mappings.yaml
@@ -3,6 +3,7 @@
options:
    package-name: net.hostsharing.hsadminng.hs.office.generated.api.v1
    model-name-suffix: Resource
    bean-validation: true
map:
    result: org.springframework.http.ResponseEntity
@@ -10,6 +11,7 @@
    types:
        - type: array => java.util.List
        - type: string:uuid => java.util.UUID
        - type: string:format => java.lang.String
    paths:
        /api/hs/office/partners/{partnerUUID}:
@@ -24,3 +26,5 @@
            null: org.openapitools.jackson.nullable.JsonNullable
        /api/hs/office/debitors/{debitorUUID}:
            null: org.openapitools.jackson.nullable.JsonNullable
        /api/hs/office/sepamandates/{debitorUUID}:
            null: org.openapitools.jackson.nullable.JsonNullable
src/main/resources/api-definition/hs-office/hs-office-sepamandate-schemas.yaml
New file
@@ -0,0 +1,60 @@
components:
    schemas:
        HsOfficeSepaMandate:
            type: object
            properties:
                uuid:
                    type: string
                    format: uuid
                debitor:
                    $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
                bankAccount:
                    $ref: './hs-office-bankaccount-schemas.yaml#/components/schemas/HsOfficeBankAccount'
                reference:
                   type: string
                validFrom:
                   type: string
                   format: date
                validTo:
                   type: string
                   format: date
        HsOfficeSepaMandatePatch:
            type: object
            properties:
                validTo:
                    type: string
                    format: date
            additionalProperties: false
        HsOfficeSepaMandateInsert:
            type: object
            properties:
                debitorUuid:
                    type: string
                    format: uuid
                    nullable: false
                bankAccountUuid:
                    type: string
                    format: uuid
                    nullable: false
                reference:
                    type: string
                    nullable: false
                validFrom:
                    type: string
                    format: date
                    nullable: false
                validTo:
                    type: string
                    format: date
                    nullable: true
            required:
                - debitorUuid
                - bankAccountUuid
                - reference
                - validFrom
            additionalProperties: false
src/main/resources/api-definition/hs-office/hs-office-sepamandates-with-uuid.yaml
New file
@@ -0,0 +1,83 @@
get:
    tags:
        - hs-office-sepaMandates
    description: 'Fetch a single SEPA Mandate by its uuid, if visible for the current subject.'
    operationId: getSepaMandateByUuid
    parameters:
        - $ref: './auth.yaml#/components/parameters/currentUser'
        - $ref: './auth.yaml#/components/parameters/assumedRoles'
        - name: sepaMandateUUID
          in: path
          required: true
          schema:
              type: string
              format: uuid
          description: UUID of the SEPA Mandate to fetch.
    responses:
        "200":
            description: OK
            content:
                'application/json':
                    schema:
                        $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate'
        "401":
            $ref: './error-responses.yaml#/components/responses/Unauthorized'
        "403":
            $ref: './error-responses.yaml#/components/responses/Forbidden'
patch:
    tags:
        - hs-office-sepaMandates
    description: 'Updates a single SEPA Mandate by its uuid, if permitted for the current subject.'
    operationId: patchSepaMandate
    parameters:
        -   $ref: './auth.yaml#/components/parameters/currentUser'
        -   $ref: './auth.yaml#/components/parameters/assumedRoles'
        -   name: sepaMandateUUID
            in: path
            required: true
            schema:
                type: string
                format: uuid
    requestBody:
        content:
            'application/json':
                schema:
                    $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandatePatch'
    responses:
        "200":
            description: OK
            content:
                'application/json':
                    schema:
                        $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate'
        "401":
            $ref: './error-responses.yaml#/components/responses/Unauthorized'
        "403":
            $ref: './error-responses.yaml#/components/responses/Forbidden'
delete:
    tags:
        - hs-office-sepaMandates
    description: 'Delete a single SEPA Mandate by its uuid, if permitted for the current subject.'
    operationId: deleteSepaMandateByUuid
    parameters:
        - $ref: './auth.yaml#/components/parameters/currentUser'
        - $ref: './auth.yaml#/components/parameters/assumedRoles'
        - name: sepaMandateUUID
          in: path
          required: true
          schema:
              type: string
              format: uuid
          description: UUID of the sepaMandate to delete.
    responses:
        "204":
            description: No Content
        "401":
            $ref: './error-responses.yaml#/components/responses/Unauthorized'
        "403":
            $ref: './error-responses.yaml#/components/responses/Forbidden'
        "404":
            $ref: './error-responses.yaml#/components/responses/NotFound'
src/main/resources/api-definition/hs-office/hs-office-sepamandates.yaml
New file
@@ -0,0 +1,57 @@
get:
    summary: Returns a list of (optionally filtered) SEPA Mandates.
    description: Returns the list of (optionally filtered) SEPA Mandates which are visible to the current user or any of it's assumed roles.
    tags:
        - hs-office-sepaMandates
    operationId: listSepaMandatesByIBAN
    parameters:
        - $ref: './auth.yaml#/components/parameters/currentUser'
        - $ref: './auth.yaml#/components/parameters/assumedRoles'
        - name: name
          in: query
          required: false
          schema:
              type: string
          description: (Beginning of) IBAN to filter the results.
    responses:
        "200":
            description: OK
            content:
                'application/json':
                    schema:
                        type: array
                        items:
                            $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate'
        "401":
            $ref: './error-responses.yaml#/components/responses/Unauthorized'
        "403":
            $ref: './error-responses.yaml#/components/responses/Forbidden'
post:
    summary: Adds a new SEPA Mandate.
    tags:
        - hs-office-sepaMandates
    operationId: addSepaMandate
    parameters:
        - $ref: './auth.yaml#/components/parameters/currentUser'
        - $ref: './auth.yaml#/components/parameters/assumedRoles'
    requestBody:
        description: A JSON object describing the new SEPA-Mandate.
        required: true
        content:
            application/json:
                schema:
                    $ref: '/hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandateInsert'
    responses:
        "201":
            description: Created
            content:
                'application/json':
                    schema:
                        $ref: './hs-office-sepamandate-schemas.yaml#/components/schemas/HsOfficeSepaMandate'
        "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
@@ -60,3 +60,12 @@
  /api/hs/office/debitors/{debitorUUID}:
    $ref: "./hs-office-debitors-with-uuid.yaml"
  # SepaMandates
  /api/hs/office/sepamandates:
    $ref: "./hs-office-sepamandates.yaml"
  /api/hs/office/sepamandates/{sepaMandateUUID}:
    $ref: "./hs-office-sepamandates-with-uuid.yaml"
src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java
@@ -36,7 +36,7 @@
class HsOfficeBankAccountRepositoryIntegrationTest extends ContextBasedTest {
    @Autowired
    HsOfficeBankAccountRepository bankaccountRepo;
    HsOfficeBankAccountRepository bankAccountRepo;
    @Autowired
    RawRbacRoleRepository rawRoleRepo;
@@ -60,37 +60,37 @@
    class CreateBankAccount {
        @Test
        public void globalAdmin_withoutAssumedRole_canCreateNewBankAccount() {
        public void globalAdmin_canCreateNewBankAccount() {
            // given
            context("superuser-alex@hostsharing.net");
            final var count = bankaccountRepo.count();
            final var count = bankAccountRepo.count();
            // when
            final var result = attempt(em, () -> bankaccountRepo.save(
            final var result = attempt(em, () -> bankAccountRepo.save(
                    hsOfficeBankAccount("some temp acc A", "DE37500105177419788228", "")));
            // then
            result.assertSuccessful();
            assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeBankAccountEntity::getUuid).isNotNull();
            assertThatBankAccountIsPersisted(result.returnedValue());
            assertThat(bankaccountRepo.count()).isEqualTo(count + 1);
            assertThat(bankAccountRepo.count()).isEqualTo(count + 1);
        }
        @Test
        public void arbitraryUser_canCreateNewBankAccount() {
            // given
            context("selfregistered-user-drew@hostsharing.org");
            final var count = bankaccountRepo.count();
            final var count = bankAccountRepo.count();
            // when
            final var result = attempt(em, () -> bankaccountRepo.save(
            final var result = attempt(em, () -> bankAccountRepo.save(
                    hsOfficeBankAccount("some temp acc B", "DE49500105174516484892", "INGDDEFFXXX")));
            // then
            result.assertSuccessful();
            assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeBankAccountEntity::getUuid).isNotNull();
            assertThatBankAccountIsPersisted(result.returnedValue());
            assertThat(bankaccountRepo.count()).isEqualTo(count + 1);
            assertThat(bankAccountRepo.count()).isEqualTo(count + 1);
        }
        @Test
@@ -101,7 +101,7 @@
            final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll());
            // when
            attempt(em, () -> bankaccountRepo.save(
            attempt(em, () -> bankAccountRepo.save(
                    hsOfficeBankAccount("some temp acc C", "DE25500105176934832579", "INGDDEFFXXX"))
            ).assumeSuccessful();
@@ -131,21 +131,21 @@
        }
        private void assertThatBankAccountIsPersisted(final HsOfficeBankAccountEntity saved) {
            final var found = bankaccountRepo.findByUuid(saved.getUuid());
            final var found = bankAccountRepo.findByUuid(saved.getUuid());
            assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved);
        }
    }
    @Nested
    class FindAllBankAccounts {
    class ListBankAccounts {
        @Test
        public void globalAdmin_withoutAssumedRole_canViewAllBankAccounts() {
        public void globalAdmin_canViewAllBankAccounts() {
            // given
            context("superuser-alex@hostsharing.net");
            // when
            final var result = bankaccountRepo.findByOptionalHolderLike(null);
            final var result = bankAccountRepo.findByOptionalHolderLike(null);
            // then
            allTheseBankAccountsAreReturned(
@@ -167,45 +167,32 @@
            // when:
            context("selfregistered-user-drew@hostsharing.org");
            final var result = bankaccountRepo.findByOptionalHolderLike(null);
            final var result = bankAccountRepo.findByOptionalHolderLike(null);
            // then:
            exactlyTheseBankAccountsAreReturned(result, givenBankAccount.getHolder());
        }
    }
    @Nested
    class FindByLabelLike {
        @Test
        public void globalAdmin_withoutAssumedRole_canViewAllBankAccounts() {
        public void globalAdmin_canViewBankAccountsByIban() {
            // given
            context("superuser-alex@hostsharing.net", null);
            // when
            final var result = bankaccountRepo.findByOptionalHolderLike(null);
            final var result = bankAccountRepo.findByIbanOrderByIban("DE02120300000000202051");
            // then
            exactlyTheseBankAccountsAreReturned(
                    result,
                    "Anita Bessler",
                    "First GmbH",
                    "Fourth e.G.",
                    "Mel Bessler",
                    "Paul Winkler",
                    "Peter Smith",
                    "Second e.K.",
                    "Third OHG");
            exactlyTheseBankAccountsAreReturned(result, "First GmbH");
        }
        @Test
        public void arbitraryUser_withoutAssumedRole_canViewOnlyItsOwnBankAccount() {
        public void arbitraryUser_canViewItsOwnBankAccount() {
            // given:
            final var givenBankAccount = givenSomeTemporaryBankAccount("selfregistered-user-drew@hostsharing.org");
            // when:
            context("selfregistered-user-drew@hostsharing.org");
            final var result = bankaccountRepo.findByOptionalHolderLike(givenBankAccount.getHolder());
            final var result = bankAccountRepo.findByIbanOrderByIban(givenBankAccount.getIban());
            // then:
            exactlyTheseBankAccountsAreReturned(result, givenBankAccount.getHolder());
@@ -216,7 +203,7 @@
    class DeleteByUuid {
        @Test
        public void globalAdmin_withoutAssumedRole_canDeleteAnyBankAccount() {
        public void globalAdmin_canDeleteAnyBankAccount() {
            // given
            context("superuser-alex@hostsharing.net", null);
            final var givenBankAccount = givenSomeTemporaryBankAccount("selfregistered-user-drew@hostsharing.org");
@@ -224,33 +211,33 @@
            // when
            final var result = jpaAttempt.transacted(() -> {
                context("superuser-alex@hostsharing.net", null);
                bankaccountRepo.deleteByUuid(givenBankAccount.getUuid());
                bankAccountRepo.deleteByUuid(givenBankAccount.getUuid());
            });
            // then
            result.assertSuccessful();
            assertThat(jpaAttempt.transacted(() -> {
                context("superuser-alex@hostsharing.net", null);
                return bankaccountRepo.findByOptionalHolderLike(givenBankAccount.getHolder());
                return bankAccountRepo.findByOptionalHolderLike(givenBankAccount.getHolder());
            }).assertSuccessful().returnedValue()).hasSize(0);
        }
        @Test
        public void arbitraryUser_withoutAssumedRole_canDeleteABankAccountCreatedByItself() {
        public void arbitraryUser_canDeleteABankAccountCreatedByItself() {
            // given
            final var givenBankAccount = givenSomeTemporaryBankAccount("selfregistered-user-drew@hostsharing.org");
            // when
            final var result = jpaAttempt.transacted(() -> {
                context("selfregistered-user-drew@hostsharing.org", null);
                bankaccountRepo.deleteByUuid(givenBankAccount.getUuid());
                bankAccountRepo.deleteByUuid(givenBankAccount.getUuid());
            });
            // then
            result.assertSuccessful();
            assertThat(jpaAttempt.transacted(() -> {
                context("superuser-alex@hostsharing.net", null);
                return bankaccountRepo.findByOptionalHolderLike(givenBankAccount.getHolder());
                return bankAccountRepo.findByOptionalHolderLike(givenBankAccount.getHolder());
            }).assertSuccessful().returnedValue()).hasSize(0);
        }
@@ -269,7 +256,7 @@
            // when
            final var result = jpaAttempt.transacted(() -> {
                context("selfregistered-user-drew@hostsharing.org", null);
                return bankaccountRepo.deleteByUuid(givenBankAccount.getUuid());
                return bankAccountRepo.deleteByUuid(givenBankAccount.getUuid());
            });
            // then
@@ -289,7 +276,7 @@
            Supplier<HsOfficeBankAccountEntity> entitySupplier) {
        return jpaAttempt.transacted(() -> {
            context(createdByUser);
            return bankaccountRepo.save(entitySupplier.get());
            return bankAccountRepo.save(entitySupplier.get());
        }).assertSuccessful().returnedValue();
    }
@@ -316,10 +303,10 @@
    @AfterEach
    void cleanup() {
        context("superuser-alex@hostsharing.net", null);
        final var result = bankaccountRepo.findByOptionalHolderLike("some temp acc");
        final var result = bankAccountRepo.findByOptionalHolderLike("some temp acc");
        result.forEach(tempPerson -> {
            System.out.println("DELETING temporary bankaccount: " + tempPerson.getHolder());
            bankaccountRepo.deleteByUuid(tempPerson.getUuid());
            bankAccountRepo.deleteByUuid(tempPerson.getUuid());
        });
    }
src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java
New file
@@ -0,0 +1,511 @@
package net.hostsharing.hsadminng.hs.office.sepamandate;
import com.vladmihalcea.hibernate.type.range.Range;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.Accepts;
import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;
import net.hostsharing.test.JpaAttempt;
import org.json.JSONException;
import org.junit.jupiter.api.AfterEach;
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 java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
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.*;
@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        classes = { HsadminNgApplication.class, JpaAttempt.class }
)
@Transactional
class HsOfficeSepaMandateControllerAcceptanceTest {
    @LocalServerPort
    private Integer port;
    @Autowired
    Context context;
    @Autowired
    Context contextMock;
    @Autowired
    HsOfficeSepaMandateRepository sepaMandateRepo;
    @Autowired
    HsOfficeDebitorRepository debitorRepo;
    @Autowired
    HsOfficeBankAccountRepository bankAccountRepo;
    @Autowired
    JpaAttempt jpaAttempt;
    Set<UUID> tempSepaMandateUuids = new HashSet<>();
    @Nested
    @Accepts({ "SepaMandate:F(Find)" })
    class ListSepaMandates {
        @Test
        void globalAdmin_canViewAllSepaMandates_ifNoCriteriaGiven() throws JSONException {
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .port(port)
                .when()
                    .get("http://localhost/api/hs/office/sepamandates")
                .then().log().all().assertThat()
                    .statusCode(200)
                    .contentType("application/json")
                    .body("", lenientlyEquals("""
                    [
                         {
                             "debitor": {
                                 "debitorNumber": 10002,
                                 "billingContact": { "label": "second contact" }
                             },
                             "bankAccount": { "holder": "Second e.K." },
                             "reference": "refSeconde.K.",
                             "validFrom": "2022-10-01",
                             "validTo": "2026-12-31"
                         },
                         {
                             "debitor": {
                                 "debitorNumber": 10001,
                                 "billingContact": { "label": "first contact" }
                             },
                             "bankAccount": { "holder": "First GmbH" },
                             "reference": "refFirstGmbH",
                             "validFrom": "2022-10-01",
                             "validTo": "2026-12-31"
                         },
                         {
                             "debitor": {
                                 "debitorNumber": 10003,
                                 "billingContact": { "label": "third contact" }
                             },
                             "bankAccount": { "holder": "Third OHG" },
                             "reference": "refThirdOHG",
                             "validFrom": "2022-10-01",
                             "validTo": "2026-12-31"
                         }
                     ]
                    """));
                // @formatter:on
        }
    }
    @Nested
    @Accepts({ "SepaMandate:C(Create)" })
    class AddSepaMandate {
        @Test
        void globalAdmin_canAddSepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("Third").get(0);
            final var givenBankAccount = bankAccountRepo.findByIbanOrderByIban("DE02200505501015871393").get(0);
            final var location = RestAssured // @formatter:off
                    .given()
                        .header("current-user", "superuser-alex@hostsharing.net")
                        .contentType(ContentType.JSON)
                        .body("""
                               {
                                   "debitorUuid": "%s",
                                   "bankAccountUuid": "%s",
                                   "reference": "temp ref A",
                                   "validFrom": "2022-10-13"
                                 }
                            """.formatted(givenDebitor.getUuid(), givenBankAccount.getUuid()))
                        .port(port)
                    .when()
                        .post("http://localhost/api/hs/office/sepamandates")
                    .then().log().all().assertThat()
                        .statusCode(201)
                        .contentType(ContentType.JSON)
                        .body("uuid", isUuidValid())
                        .body("debitor.partner.person.tradeName", is("Third OHG"))
                        .body("bankAccount.iban", is("DE02200505501015871393"))
                        .body("reference", is("temp ref A"))
                        .body("validFrom", is("2022-10-13"))
                        .body("validTo", equalTo(null))
                        .header("Location", startsWith("http://localhost"))
                    .extract().header("Location");  // @formatter:on
            // finally, the new sepaMandate can be accessed under the generated UUID
            final var newUserUuid = toCleanup(UUID.fromString(
                    location.substring(location.lastIndexOf('/') + 1)));
            assertThat(newUserUuid).isNotNull();
        }
        // TODO.test: move validation tests to a ...WebMvcTest
        @Test
        void globalAdmin_canNotAddSepaMandateWhenDebitorUuidIsMissing() {
            context.define("superuser-alex@hostsharing.net");
            final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("Third").get(0);
            final var givenBankAccount = bankAccountRepo.findByIbanOrderByIban("DE02200505501015871393").get(0);
            final var location = RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .contentType(ContentType.JSON)
                    .body("""
                               {
                                   "bankAccountUuid": "%s",
                                   "reference": "temp ref A",
                                   "validFrom": "2022-10-13"
                                 }
                            """.formatted(givenBankAccount.getUuid()))
                    .port(port)
                .when()
                    .post("http://localhost/api/hs/office/sepamandates")
                .then().assertThat()
                    .statusCode(400);  // @formatter:on
        }
        @Test
        void globalAdmin_canNotAddSepaMandate_ifBankAccountDoesNotExist() {
            context.define("superuser-alex@hostsharing.net");
            final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("Third").get(0);
            final var givenBankAccountUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6");
            final var location = RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .contentType(ContentType.JSON)
                    .body("""
                               {
                                   "debitorUuid": "%s",
                                   "bankAccountUuid": "%s",
                                   "reference": "temp ref A",
                                   "validFrom": "2022-10-13",
                                   "validTo": "2024-12-31"
                                 }
                            """.formatted(givenDebitor.getUuid(), givenBankAccountUuid))
                    .port(port)
                .when()
                    .post("http://localhost/api/hs/office/sepamandates")
                .then().log().all().assertThat()
                    .statusCode(400)
                    .body("message", is("Unable to find BankAccount with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6"));
            // @formatter:on
        }
        @Test
        void globalAdmin_canNotAddSepaMandate_ifPersonDoesNotExist() {
            context.define("superuser-alex@hostsharing.net");
            final var givenDebitorUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6");
            final var givenBankAccount = bankAccountRepo.findByIbanOrderByIban("DE02200505501015871393").get(0);
            final var location = RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .contentType(ContentType.JSON)
                    .body("""
                               {
                                   "debitorUuid": "%s",
                                   "bankAccountUuid": "%s",
                                   "reference": "temp ref A",
                                   "validFrom": "2022-10-13",
                                   "validTo": "2024-12-31"
                                 }
                            """.formatted(givenDebitorUuid, givenBankAccount.getUuid()))
                    .port(port)
                .when()
                    .post("http://localhost/api/hs/office/sepamandates")
                .then().log().all().assertThat()
                    .statusCode(400)
                    .body("message", is("Unable to find Debitor with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6"));
                // @formatter:on
        }
    }
    @Nested
    @Accepts({ "SepaMandate:R(Read)" })
    class GetSepaMandate {
        @Test
        void globalAdmin_canGetArbitrarySepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenSepaMandateUuid = sepaMandateRepo.findSepaMandateByOptionalIban("DE02120300000000202051")
                    .get(0)
                    .getUuid();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .port(port)
                .when()
                    .get("http://localhost/api/hs/office/sepamandates/" + givenSepaMandateUuid)
                .then().log().body().assertThat()
                    .statusCode(200)
                    .contentType("application/json")
                    .body("", lenientlyEquals("""
                    {
                         "debitor": {
                             "debitorNumber": 10001,
                             "billingContact": { "label": "first contact" }
                         },
                         "bankAccount": {
                            "holder": "First GmbH",
                            "iban": "DE02120300000000202051"
                         },
                         "reference": "refFirstGmbH",
                         "validFrom": "2022-10-01",
                         "validTo": "2026-12-31"
                     }
                    """)); // @formatter:on
        }
        @Test
        @Accepts({ "SepaMandate:X(Access Control)" })
        void normalUser_canNotGetUnrelatedSepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenSepaMandateUuid = sepaMandateRepo.findSepaMandateByOptionalIban("DE02120300000000202051")
                    .get(0)
                    .getUuid();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "selfregistered-user-drew@hostsharing.org")
                    .port(port)
                .when()
                    .get("http://localhost/api/hs/office/sepamandates/" + givenSepaMandateUuid)
                .then().log().body().assertThat()
                    .statusCode(404); // @formatter:on
        }
        @Test
        @Accepts({ "SepaMandate:X(Access Control)" })
        void bankAccountAdminUser_canGetRelatedSepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenSepaMandateUuid = sepaMandateRepo.findSepaMandateByOptionalIban("DE02120300000000202051")
                    .get(0)
                    .getUuid();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "bankaccount-admin@FirstGmbH.example.com")
                    .port(port)
                .when()
                    .get("http://localhost/api/hs/office/sepamandates/" + givenSepaMandateUuid)
                .then().log().body().assertThat()
                    .statusCode(200)
                    .contentType("application/json")
                    .body("", lenientlyEquals("""
                    {
                         "debitor": {
                             "debitorNumber": 10001,
                             "billingContact": { "label": "first contact" }
                         },
                         "bankAccount": {
                            "holder": "First GmbH",
                            "iban": "DE02120300000000202051"
                         },
                         "reference": "refFirstGmbH",
                         "validFrom": "2022-10-01",
                         "validTo": "2026-12-31"
                    }
                    """)); // @formatter:on
        }
    }
    @Nested
    @Accepts({ "SepaMandate:U(Update)" })
    class PatchSepaMandate {
        @Test
        void globalAdmin_canPatchValidToOfArbitrarySepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenSepaMandate = givenSomeTemporarySepaMandateBessler();
            final var location = RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .contentType(ContentType.JSON)
                    .body("""
                           {
                               "validTo": "2022-12-31"
                           }
                          """)
                    .port(port)
                .when()
                    .patch("http://localhost/api/hs/office/sepamandates/" + givenSepaMandate.getUuid())
                .then().log().all().assertThat()
                    .statusCode(200)
                    .contentType(ContentType.JSON)
                    .body("uuid", isUuidValid())
                    .body("debitor.partner.person.tradeName", is("First GmbH"))
                    .body("bankAccount.iban", is("DE02120300000000202051"))
                    .body("reference", is("temp ref X"))
                    .body("validFrom", is("2022-11-01"))
                    .body("validTo", is("2022-12-31"));
            // @formatter:on
            // finally, the sepaMandate is actually updated
            assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isPresent().get()
                    .matches(mandate -> {
                        assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(10001: First GmbH)");
                        assertThat(mandate.getBankAccount().toShortString()).isEqualTo("First GmbH");
                        assertThat(mandate.getReference()).isEqualTo("temp ref X");
                        assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2023-01-01)");
                        return true;
                    });
        }
        @Test
        void globalAdmin_canNotPatchReferenceOfArbitrarySepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenSepaMandate = givenSomeTemporarySepaMandateBessler();
            final var location = RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .contentType(ContentType.JSON)
                    .body("""
                           {
                               "reference": "new ref"
                           }
                           """)
                    .port(port)
                .when()
                    .patch("http://localhost/api/hs/office/sepamandates/" + givenSepaMandate.getUuid())
                .then().assertThat()
                    // TODO.impl: I'd prefer a 400,
                    //      but OpenApi Spring Code Gen does not convert additonalProperties=false into a validation
                    .statusCode(200); // @formatter:on
            // finally, the sepaMandate is actually updated
            assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isPresent().get()
                    .matches(mandate -> {
                        assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,)");
                        return true;
                    });
        }
    }
    @Nested
    @Accepts({ "SepaMandate:D(Delete)" })
    class DeleteSepaMandate {
        @Test
        void globalAdmin_canDeleteArbitrarySepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenSepaMandate = givenSomeTemporarySepaMandateBessler();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .port(port)
                .when()
                    .delete("http://localhost/api/hs/office/sepamandates/" + givenSepaMandate.getUuid())
                .then().log().body().assertThat()
                    .statusCode(204); // @formatter:on
            // then the given sepaMandate is gone
            assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isEmpty();
        }
        @Test
        @Accepts({ "SepaMandate:X(Access Control)" })
        void bankAccountAdminUser_canNotDeleteRelatedSepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenSepaMandate = givenSomeTemporarySepaMandateBessler();
            assertThat(givenSepaMandate.getReference()).isEqualTo("temp ref X");
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "bankaccount-admin@FirstGmbH.example.com")
                    .port(port)
                .when()
                    .delete("http://localhost/api/hs/office/sepamandates/" + givenSepaMandate.getUuid())
                .then().log().body().assertThat()
                    .statusCode(403); // @formatter:on
            // then the given sepaMandate is still there
            assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isNotEmpty();
        }
        @Test
        @Accepts({ "SepaMandate:X(Access Control)" })
        void normalUser_canNotDeleteUnrelatedSepaMandate() {
            context.define("superuser-alex@hostsharing.net");
            final var givenSepaMandate = givenSomeTemporarySepaMandateBessler();
            assertThat(givenSepaMandate.getReference()).isEqualTo("temp ref X");
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "selfregistered-user-drew@hostsharing.org")
                    .port(port)
                .when()
                    .delete("http://localhost/api/hs/office/sepamandates/" + givenSepaMandate.getUuid())
                .then().log().body().assertThat()
                    .statusCode(404); // @formatter:on
            // then the given sepaMandate is still there
            assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isNotEmpty();
        }
    }
    private HsOfficeSepaMandateEntity givenSomeTemporarySepaMandateBessler() {
        return jpaAttempt.transacted(() -> {
            context.define("superuser-alex@hostsharing.net");
            final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0);
            final var givenBankAccount = bankAccountRepo.findByOptionalHolderLike("First").get(0);
            final var newSepaMandate = HsOfficeSepaMandateEntity.builder()
                    .uuid(UUID.randomUUID())
                    .debitor(givenDebitor)
                    .bankAccount(givenBankAccount)
                    .reference("temp ref X")
                    .validity(Range.closedOpen(
                            LocalDate.parse("2022-11-01"), LocalDate.parse("2023-03-31")))
                    .build();
            toCleanup(newSepaMandate.getUuid());
            return sepaMandateRepo.save(newSepaMandate);
        }).assertSuccessful().returnedValue();
    }
    private UUID toCleanup(final UUID tempSepaMandateUuid) {
        tempSepaMandateUuids.add(tempSepaMandateUuid);
        return tempSepaMandateUuid;
    }
    @AfterEach
    void cleanup() {
        tempSepaMandateUuids.forEach(uuid -> {
            jpaAttempt.transacted(() -> {
                context.define("superuser-alex@hostsharing.net", null);
                System.out.println("DELETING temporary sepaMandate: " + uuid);
                final var count = sepaMandateRepo.deleteByUuid(uuid);
                System.out.println("DELETED temporary sepaMandate: " + uuid + (count > 0 ? " successful" : " failed"));
            });
        });
    }
}
src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java
@@ -176,7 +176,7 @@
            context("superuser-alex@hostsharing.net");
            // when
            final var result = sepaMandateRepo.findSepaMandateByOptionalIBAN(null);
            final var result = sepaMandateRepo.findSepaMandateByOptionalIban(null);
            // then
            allTheseSepaMandatesAreReturned(
@@ -192,7 +192,7 @@
            context("bankaccount-admin@FirstGmbH.example.com");
            // when:
            final var result = sepaMandateRepo.findSepaMandateByOptionalIBAN(null);
            final var result = sepaMandateRepo.findSepaMandateByOptionalIban(null);
            // then:
            exactlyTheseSepaMandatesAreReturned(
@@ -210,7 +210,7 @@
            context("superuser-alex@hostsharing.net");
            // when
            final var result = sepaMandateRepo.findSepaMandateByOptionalIBAN(null);
            final var result = sepaMandateRepo.findSepaMandateByOptionalIban(null);
            // then
            exactlyTheseSepaMandatesAreReturned(
@@ -226,7 +226,7 @@
            context("bankaccount-admin@ThirdOHG.example.com");
            // when
            final var result = sepaMandateRepo.findSepaMandateByOptionalIBAN(null);
            final var result = sepaMandateRepo.findSepaMandateByOptionalIban(null);
            // then
            exactlyTheseSepaMandatesAreReturned(