Michael Hoennig
2022-10-18 c862df78465f0028e54c97ac55b4f071c9ec7991
add hs-office-membership API and controller
5 files added
4 files modified
923 ■■■■■ changed files
src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java 154 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/api-mappings.yaml 2 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml 75 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml 83 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office-memberships.yaml 64 ●●●●● patch | view | raw | blame | history
src/main/resources/api-definition/hs-office/hs-office.yaml 9 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/300-hs-office-membership.sql 2 ●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java 476 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java 58 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java
New file
@@ -0,0 +1,154 @@
package net.hostsharing.hsadminng.hs.office.membership;
import com.vladmihalcea.hibernate.type.range.Range;
import net.hostsharing.hsadminng.Mapper;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeMembershipsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipResource;
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.Optional;
import java.util.UUID;
import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.Mapper.map;
@RestController
public class HsOfficeMembershipController implements HsOfficeMembershipsApi {
    @Autowired
    private Context context;
    @Autowired
    private HsOfficeMembershipRepository membershipRepo;
    @Autowired
    private EntityManager em;
    @Override
    @Transactional(readOnly = true)
    public ResponseEntity<List<HsOfficeMembershipResource>> listMemberships(
            final String currentUser,
            final String assumedRoles,
            UUID partnerUuid,
            Integer memberNumber) {
        context.define(currentUser, assumedRoles);
        final var entities =
                membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(partnerUuid, memberNumber);
        final var resources = Mapper.mapList(entities, HsOfficeMembershipResource.class,
                SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER);
        return ResponseEntity.ok(resources);
    }
    @Override
    @Transactional
    public ResponseEntity<HsOfficeMembershipResource> addMembership(
            final String currentUser,
            final String assumedRoles,
            @Valid final HsOfficeMembershipInsertResource body) {
        context.define(currentUser, assumedRoles);
        final var entityToSave = map(body, HsOfficeMembershipEntity.class, SEPA_MANDATE_RESOURCE_TO_ENTITY_POSTMAPPER);
        entityToSave.setUuid(UUID.randomUUID());
        final var saved = membershipRepo.save(entityToSave);
        final var uri =
                MvcUriComponentsBuilder.fromController(getClass())
                        .path("/api/hs/office/Memberships/{id}")
                        .buildAndExpand(entityToSave.getUuid())
                        .toUri();
        final var mapped = map(saved, HsOfficeMembershipResource.class,
                SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER);
        return ResponseEntity.created(uri).body(mapped);
    }
    @Override
    @Transactional(readOnly = true)
    public ResponseEntity<HsOfficeMembershipResource> getMembershipByUuid(
            final String currentUser,
            final String assumedRoles,
            final UUID membershipUuid) {
        context.define(currentUser, assumedRoles);
        final var result = membershipRepo.findByUuid(membershipUuid);
        if (result.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(map(result.get(), HsOfficeMembershipResource.class,
                SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER));
    }
    @Override
    @Transactional
    public ResponseEntity<Void> deleteMembershipByUuid(
            final String currentUser,
            final String assumedRoles,
            final UUID membershipUuid) {
        context.define(currentUser, assumedRoles);
        final var result = membershipRepo.deleteByUuid(membershipUuid);
        if (result == 0) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.noContent().build();
    }
    @Override
    @Transactional
    public ResponseEntity<HsOfficeMembershipResource> patchMembership(
            final String currentUser,
            final String assumedRoles,
            final UUID membershipUuid,
            final HsOfficeMembershipPatchResource body) {
        context.define(currentUser, assumedRoles);
        final var current = membershipRepo.findByUuid(membershipUuid).orElseThrow();
        current.setValidity(toPostgresDateRange(current.getValidity().lower(), body.getValidTo()));
//        current.setReasonForTermination(HsOfficeReasonForTermination.valueOf(body.getReasonForTermination().name()));
        current.setReasonForTermination(
                Optional.ofNullable(body.getReasonForTermination()).map(Enum::name).map(HsOfficeReasonForTermination::valueOf).orElse(current.getReasonForTermination())
        );
        final var saved = membershipRepo.save(current);
        final var mapped = map(saved, HsOfficeMembershipResource.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<HsOfficeMembershipEntity, HsOfficeMembershipResource> SEPA_MANDATE_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
        resource.setValidFrom(entity.getValidity().lower());
        if (entity.getValidity().hasUpperBound()) {
            resource.setValidTo(entity.getValidity().upper().minusDays(1));
        }
    };
    final BiConsumer<HsOfficeMembershipInsertResource, HsOfficeMembershipEntity> SEPA_MANDATE_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
        entity.setValidity(toPostgresDateRange(resource.getValidFrom(), resource.getValidTo()));
    };
}
src/main/resources/api-definition/hs-office/api-mappings.yaml
@@ -28,3 +28,5 @@
            null: org.openapitools.jackson.nullable.JsonNullable
        /api/hs/office/sepamandates/{debitorUUID}:
            null: org.openapitools.jackson.nullable.JsonNullable
        /api/hs/office/memberships/{membershipUUID}:
            null: org.openapitools.jackson.nullable.JsonNullable
src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml
New file
@@ -0,0 +1,75 @@
components:
    schemas:
        HsOfficeReasonForTermination:
            type: string
            enum:
                - NONE
                - CANCELLATION
                - TRANSFER
                - DEATH
                - LIQUIDATION
                - EXPULSION
        HsOfficeMembership:
            type: object
            properties:
                uuid:
                    type: string
                    format: uuid
                partner:
                    $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner'
                mainDebitor:
                    $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
                memberNumber:
                    type: integer
                validFrom:
                   type: string
                   format: date
                validTo:
                   type: string
                   format: date
                reasonForTermination:
                   $ref: '#/components/schemas/HsOfficeReasonForTermination'
        HsOfficeMembershipPatch:
            type: object
            properties:
                validTo:
                    type: string
                    format: date
                reasonForTermination:
                    $ref: '#/components/schemas/HsOfficeReasonForTermination'
            additionalProperties: false
        HsOfficeMembershipInsert:
            type: object
            properties:
                partnerUuid:
                    type: string
                    format: uuid
                    nullable: false
                mainDebitorUuid:
                    type: string
                    format: uuid
                    nullable: false
                memberNumber:
                    type: integer
                    nullable: false
                validFrom:
                    type: string
                    format: date
                    nullable: false
                validTo:
                    type: string
                    format: date
                    nullable: true
                reasonForTermination:
                    $ref: '#/components/schemas/HsOfficeReasonForTermination'
            required:
                - partnerUuid
                - debitorUuid
                - validFrom
            additionalProperties: false
src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml
New file
@@ -0,0 +1,83 @@
get:
    tags:
        - hs-office-memberships
    description: 'Fetch a single membership by its uuid, if visible for the current subject.'
    operationId: getMembershipByUuid
    parameters:
        - $ref: './auth.yaml#/components/parameters/currentUser'
        - $ref: './auth.yaml#/components/parameters/assumedRoles'
        - name: membershipUUID
          in: path
          required: true
          schema:
              type: string
              format: uuid
          description: UUID of the membership to fetch.
    responses:
        "200":
            description: OK
            content:
                'application/json':
                    schema:
                        $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership'
        "401":
            $ref: './error-responses.yaml#/components/responses/Unauthorized'
        "403":
            $ref: './error-responses.yaml#/components/responses/Forbidden'
patch:
    tags:
        - hs-office-memberships
    description: 'Updates a single membership by its uuid, if permitted for the current subject.'
    operationId: patchMembership
    parameters:
        -   $ref: './auth.yaml#/components/parameters/currentUser'
        -   $ref: './auth.yaml#/components/parameters/assumedRoles'
        -   name: membershipUUID
            in: path
            required: true
            schema:
                type: string
                format: uuid
    requestBody:
        content:
            'application/json':
                schema:
                    $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembershipPatch'
    responses:
        "200":
            description: OK
            content:
                'application/json':
                    schema:
                        $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership'
        "401":
            $ref: './error-responses.yaml#/components/responses/Unauthorized'
        "403":
            $ref: './error-responses.yaml#/components/responses/Forbidden'
delete:
    tags:
        - hs-office-memberships
    description: 'Delete a single membership by its uuid, if permitted for the current subject.'
    operationId: deleteMembershipByUuid
    parameters:
        - $ref: './auth.yaml#/components/parameters/currentUser'
        - $ref: './auth.yaml#/components/parameters/assumedRoles'
        - name: membershipUUID
          in: path
          required: true
          schema:
              type: string
              format: uuid
          description: UUID of the membership 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-memberships.yaml
New file
@@ -0,0 +1,64 @@
get:
    summary: Returns a list of (optionally filtered) memberships.
    description: Returns the list of (optionally filtered) memberships which are visible to the current user or any of it's assumed roles.
    tags:
        - hs-office-memberships
    operationId: listMemberships
    parameters:
        - $ref: './auth.yaml#/components/parameters/currentUser'
        - $ref: './auth.yaml#/components/parameters/assumedRoles'
        - name: partnerUuid
          in: query
          required: false
          schema:
              type: string
              format: uuid
          description: UUID of the business partner.
        - name: memberNumber
          in: query
          required: false
          schema:
              type: integer
          description: Member number.
    responses:
        "200":
            description: OK
            content:
                'application/json':
                    schema:
                        type: array
                        items:
                            $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership'
        "401":
            $ref: './error-responses.yaml#/components/responses/Unauthorized'
        "403":
            $ref: './error-responses.yaml#/components/responses/Forbidden'
post:
    summary: Adds a new membership.
    tags:
        - hs-office-memberships
    operationId: addMembership
    parameters:
        - $ref: './auth.yaml#/components/parameters/currentUser'
        - $ref: './auth.yaml#/components/parameters/assumedRoles'
    requestBody:
        description: A JSON object describing the new membership.
        required: true
        content:
            application/json:
                schema:
                    $ref: '/hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembershipInsert'
    responses:
        "201":
            description: Created
            content:
                'application/json':
                    schema:
                        $ref: './hs-office-membership-schemas.yaml#/components/schemas/HsOfficeMembership'
        "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
@@ -69,3 +69,12 @@
  /api/hs/office/sepamandates/{sepaMandateUUID}:
    $ref: "./hs-office-sepamandates-with-uuid.yaml"
  # Membership
  /api/hs/office/memberships:
    $ref: "./hs-office-memberships.yaml"
  /api/hs/office/memberships/{membershipUUID}:
    $ref: "./hs-office-memberships-with-uuid.yaml"
src/main/resources/db/changelog/300-hs-office-membership.sql
@@ -13,7 +13,7 @@
    uuid                    uuid unique references RbacObject (uuid) initially deferred,
    partnerUuid             uuid not null references hs_office_partner(uuid),
    mainDebitorUuid         uuid not null references hs_office_debitor(uuid),
    memberNumber            numeric(5) not null,
    memberNumber            numeric(5) not null unique,
    validity                daterange not null,
    reasonForTermination    HsOfficeReasonForTermination not null default 'NONE'
);
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java
New file
@@ -0,0 +1,476 @@
package net.hostsharing.hsadminng.hs.office.membership;
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.debitor.HsOfficeDebitorRepository;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository;
import net.hostsharing.test.JpaAttempt;
import org.json.JSONException;
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.time.LocalDate;
import java.util.UUID;
import static net.hostsharing.hsadminng.hs.office.membership.HsOfficeReasonForTermination.NONE;
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 HsOfficeMembershipControllerAcceptanceTest {
    @LocalServerPort
    private Integer port;
    @Autowired
    Context context;
    @Autowired
    Context contextMock;
    @Autowired
    HsOfficeMembershipRepository membershipRepo;
    @Autowired
    HsOfficeDebitorRepository debitorRepo;
    @Autowired
    HsOfficePartnerRepository partnerRepo;
    @Autowired
    JpaAttempt jpaAttempt;
    @Autowired
    EntityManager em;
    private static int tempMemberNumber = 20010;
    @Nested
    @Accepts({ "Membership:F(Find)" })
    class ListMemberships {
        @Test
        void globalAdmin_canViewAllMemberships_ifNoCriteriaGiven() throws JSONException {
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .port(port)
                .when()
                    .get("http://localhost/api/hs/office/memberships")
                .then().log().all().assertThat()
                    .statusCode(200)
                    .contentType("application/json")
                    .body("", lenientlyEquals("""
                      [
                          {
                              "partner": { "person": { "tradeName": "First GmbH" } },
                              "mainDebitor": { "debitorNumber": 10001 },
                              "memberNumber": 10001,
                              "validFrom": "2022-10-01",
                              "validTo": null,
                              "reasonForTermination": "NONE"
                          },
                          {
                              "partner": { "person": { "tradeName": "Second e.K." } },
                              "mainDebitor": { "debitorNumber": 10002 },
                              "memberNumber": 10002,
                              "validFrom": "2022-10-01",
                              "validTo": null,
                              "reasonForTermination": "NONE"
                          },
                          {
                              "partner": { "person": { "tradeName": "Third OHG" } },
                              "mainDebitor": { "debitorNumber": 10003 },
                              "memberNumber": 10003,
                              "validFrom": "2022-10-01",
                              "validTo": null,
                              "reasonForTermination": "NONE"
                          }
                      ]
                    """));
                // @formatter:on
        }
    }
    @Nested
    @Accepts({ "Membership:C(Create)" })
    class AddMembership {
        @Test
        void globalAdmin_canAddMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0);
            final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("Third").get(0);
            final var location = RestAssured // @formatter:off
                    .given()
                        .header("current-user", "superuser-alex@hostsharing.net")
                        .contentType(ContentType.JSON)
                        .body("""
                               {
                                   "partnerUuid": "%s",
                                   "mainDebitorUuid": "%s",
                                   "memberNumber": 20001,
                                   "validFrom": "2022-10-13"
                                 }
                            """.formatted(givenPartner.getUuid(), givenDebitor.getUuid()))
                        .port(port)
                    .when()
                        .post("http://localhost/api/hs/office/memberships")
                    .then().log().all().assertThat()
                        .statusCode(201)
                        .contentType(ContentType.JSON)
                        .body("uuid", isUuidValid())
                        .body("mainDebitor.debitorNumber", is(givenDebitor.getDebitorNumber()))
                        .body("partner.person.tradeName", is("Third OHG"))
                        .body("memberNumber", is(20001))
                        .body("validFrom", is("2022-10-13"))
                        .body("validTo", equalTo(null))
                        .header("Location", startsWith("http://localhost"))
                    .extract().header("Location");  // @formatter:on
            // finally, the new membership can be accessed under the generated UUID
            final var newUserUuid = UUID.fromString(
                    location.substring(location.lastIndexOf('/') + 1));
            assertThat(newUserUuid).isNotNull();
            assertThat(membershipRepo.findByUuid(newUserUuid)).isPresent();
        }
        // TODO.test: move validation tests to a ...WebMvcTest
        @Test
        void globalAdmin_canNotAddMembershipWhenDebitorUuidIsMissing() {
        }
        @Test
        void globalAdmin_canNotAddMembership_ifPartnerDoesNotExist() {
        }
        @Test
        void globalAdmin_canNotAddMembership_ifPersonDoesNotExist() {
        }
    }
    @Nested
    @Accepts({ "Membership:R(Read)" })
    class GetMembership {
        @Test
        void globalAdmin_canGetArbitraryMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenMembershipUuid = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(
                            null,
                            10001)
                    .get(0)
                    .getUuid();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .port(port)
                .when()
                    .get("http://localhost/api/hs/office/memberships/" + givenMembershipUuid)
                .then().log().body().assertThat()
                    .statusCode(200)
                    .contentType("application/json")
                    .body("", lenientlyEquals("""
                    {
                         "partner": { "person": { "tradeName": "First GmbH" } },
                         "mainDebitor": { "debitorNumber": 10001 },
                         "memberNumber": 10001,
                         "validFrom": "2022-10-01",
                         "validTo": null,
                         "reasonForTermination": "NONE"
                     }
                    """)); // @formatter:on
        }
        @Test
        @Accepts({ "Membership:X(Access Control)" })
        void normalUser_canNotGetUnrelatedMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenMembershipUuid = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(
                            null,
                            10001)
                    .get(0)
                    .getUuid();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "selfregistered-user-drew@hostsharing.org")
                    .port(port)
                .when()
                    .get("http://localhost/api/hs/office/memberships/" + givenMembershipUuid)
                .then().log().body().assertThat()
                    .statusCode(404); // @formatter:on
        }
        @Test
        @Accepts({ "Membership:X(Access Control)" })
        void debitorAgentUser_canGetRelatedMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenMembershipUuid = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(
                            null,
                            10003)
                    .get(0)
                    .getUuid();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .header("assumed-roles", "hs_office_debitor#10003ThirdOHG-thirdcontact.agent")
                    .port(port)
                .when()
                    .get("http://localhost/api/hs/office/memberships/" + givenMembershipUuid)
                .then().log().body().assertThat()
                    .statusCode(200)
                    .contentType("application/json")
                    .body("", lenientlyEquals("""
                    {
                         "partner": { "person": { "tradeName": "Third OHG" } },
                         "mainDebitor": {
                             "debitorNumber": 10003,
                             "billingContact": { "label": "third contact" }
                         },
                         "memberNumber": 10003,
                         "validFrom": "2022-10-01",
                         "validTo": null,
                         "reasonForTermination": "NONE"
                    }
                    """)); // @formatter:on
        }
    }
    @Nested
    @Accepts({ "Membership:U(Update)" })
    class PatchMembership {
        @Test
        void globalAdmin_canPatchValidToOfArbitraryMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenMembership = givenSomeTemporaryMembershipBessler();
            final var location = RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .contentType(ContentType.JSON)
                    .body("""
                           {
                               "validTo": "2023-12-31",
                               "reasonForTermination": "CANCELLATION"
                           }
                          """)
                    .port(port)
                .when()
                    .patch("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid())
                .then().log().all().assertThat()
                    .statusCode(200)
                    .contentType(ContentType.JSON)
                    .body("uuid", isUuidValid())
                    .body("partner.person.tradeName", is(givenMembership.getPartner().getPerson().getTradeName()))
                    .body("mainDebitor.debitorNumber", is(givenMembership.getMainDebitor().getDebitorNumber()))
                    .body("memberNumber", is(givenMembership.getMemberNumber()))
                    .body("validFrom", is("2022-11-01"))
                    .body("validTo", is("2023-12-31"))
                    .body("reasonForTermination", is("CANCELLATION"));
            // @formatter:on
            // finally, the Membership is actually updated
            assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent().get()
                    .matches(mandate -> {
                        assertThat(mandate.getPartner().toShortString()).isEqualTo("First GmbH");
                        assertThat(mandate.getMainDebitor().toString()).isEqualTo(givenMembership.getMainDebitor().toString());
                        assertThat(mandate.getMemberNumber()).isEqualTo(givenMembership.getMemberNumber());
                        assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2024-01-01)");
                        assertThat(mandate.getReasonForTermination()).isEqualTo(HsOfficeReasonForTermination.CANCELLATION);
                        return true;
                    });
        }
        @Test
        void globalAdmin_canPatchMainDebitorOfArbitraryMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenMembership = givenSomeTemporaryMembershipBessler();
            final var givenNewMainDebitor = debitorRepo.findDebitorByDebitorNumber(10003).get(0);
            final var location = RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .contentType(ContentType.JSON)
                    .body("""
                           {
                               "mainDebitorUuid": "%s"
                           }
                          """.formatted(givenNewMainDebitor.getUuid()))
                    .port(port)
                .when()
                    .patch("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid())
                .then().log().all().assertThat()
                    .statusCode(200)
                    .contentType(ContentType.JSON)
                    .body("uuid", isUuidValid())
                    .body("partner.person.tradeName", is(givenMembership.getPartner().getPerson().getTradeName()))
                    // TODO.impl: implement patching the mainDebitor
                    // .body("mainDebitor.debitorNumber", is(10003))
                    .body("memberNumber", is(givenMembership.getMemberNumber()))
                    .body("validFrom", is("2022-11-01"))
                    .body("validTo", nullValue())
                    .body("reasonForTermination", is("NONE"));
            // @formatter:on
            // finally, the Membership is actually updated
            assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent().get()
                    .matches(mandate -> {
                        assertThat(mandate.getPartner().toShortString()).isEqualTo("First GmbH");
                        // TODO.impl: implement patching the mainDebitor
                        // assertThat(mandate.getMainDebitor().toString()).isEqualTo(givenMembership.getMainDebitor().toString());
                        assertThat(mandate.getMemberNumber()).isEqualTo(givenMembership.getMemberNumber());
                        assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,)");
                        assertThat(mandate.getReasonForTermination()).isEqualTo(NONE);
                        return true;
                    });
        }
        @Test
        void partnerAgent_canViewButNotPatchValidityOfRelatedMembership() {
            context.define("superuser-alex@hostsharing.net", "hs_office_partner#FirstGmbH-firstcontact.agent");
            final var givenMembership = givenSomeTemporaryMembershipBessler();
            final var location = RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .header("assumed-roles", "hs_office_partner#FirstGmbH-firstcontact.agent")
                    .contentType(ContentType.JSON)
                    .body("""
                           {
                               "validTo": "2023-12-31",
                               "reasonForTermination": "CANCELLATION"
                           }
                           """)
                    .port(port)
                .when()
                    .patch("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid())
                .then().assertThat()
                    .statusCode(403); // @formatter:on
            // finally, the Membership is actually updated
            assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent().get()
                    .matches(mandate -> {
                        assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,)");
                        assertThat(mandate.getReasonForTermination()).isEqualTo(NONE);
                        return true;
                    });
        }
    }
    @Nested
    @Accepts({ "Membership:D(Delete)" })
    class DeleteMembership {
        @Test
        void globalAdmin_canDeleteArbitraryMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenMembership = givenSomeTemporaryMembershipBessler();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .port(port)
                .when()
                    .delete("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid())
                .then().log().body().assertThat()
                    .statusCode(204); // @formatter:on
            // then the given Membership is gone
            assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isEmpty();
        }
        @Test
        @Accepts({ "Membership:X(Access Control)" })
        void partnerAgentUser_canNotDeleteRelatedMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenMembership = givenSomeTemporaryMembershipBessler();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "superuser-alex@hostsharing.net")
                    .header("assumed-roles", "hs_office_partner#FirstGmbH-firstcontact.agent")
                    .port(port)
                .when()
                    .delete("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid())
                .then().log().body().assertThat()
                    .statusCode(403); // @formatter:on
            // then the given Membership is still there
            assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isNotEmpty();
        }
        @Test
        @Accepts({ "Membership:X(Access Control)" })
        void normalUser_canNotDeleteUnrelatedMembership() {
            context.define("superuser-alex@hostsharing.net");
            final var givenMembership = givenSomeTemporaryMembershipBessler();
            RestAssured // @formatter:off
                .given()
                    .header("current-user", "selfregistered-user-drew@hostsharing.org")
                    .port(port)
                .when()
                    .delete("http://localhost/api/hs/office/memberships/" + givenMembership.getUuid())
                .then().log().body().assertThat()
                    .statusCode(404); // @formatter:on
            // then the given Membership is still there
            assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isNotEmpty();
        }
    }
    private HsOfficeMembershipEntity givenSomeTemporaryMembershipBessler() {
        return jpaAttempt.transacted(() -> {
            context.define("superuser-alex@hostsharing.net");
            final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0);
            final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0);
            final var newMembership = HsOfficeMembershipEntity.builder()
                    .uuid(UUID.randomUUID())
                    .partner(givenPartner)
                    .mainDebitor(givenDebitor)
                    .memberNumber(++tempMemberNumber)
                    .validity(Range.closedInfinite(LocalDate.parse("2022-11-01")))
                    .reasonForTermination(NONE)
                    .build();
            return membershipRepo.save(newMembership);
        }).assertSuccessful().returnedValue();
    }
    @BeforeEach
    @AfterEach
    void cleanup() {
        jpaAttempt.transacted(() -> {
            context.define("superuser-alex@hostsharing.net", null);
            final var query = em.createQuery("DELETE FROM HsOfficeMembershipEntity m WHERE m.memberNumber >= 20000");
            query.executeUpdate();
        });
    }
}
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java
@@ -169,63 +169,49 @@
    }
    @Nested
    class FindByPartnerUuidMemberships {
    class ListMemberships {
        @Test
        public void globalAdmin_withoutAssumedRole_canViewAllMemberships() {
            // given
            context("superuser-alex@hostsharing.net");
            // when
            final var result = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, null);
            // then
            exactlyTheseMembershipsAreReturned(
                    result,
                    "Membership(10001, First GmbH, 10001, [2022-10-01,), NONE)",
                    "Membership(10002, Second e.K., 10002, [2022-10-01,), NONE)",
                    "Membership(10003, Third OHG, 10003, [2022-10-01,), NONE)");
        }
        @Test
        public void globalAdmin_withoutAssumedRole_canFindAllMembershipByPartnerUuid() {
            // given
            context("superuser-alex@hostsharing.net");
            final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0);
            // when
            final var result = membershipRepo.findMembershipsByPartnerUuid(givenPartner.getUuid());
            final var result = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(
                    givenPartner.getUuid(),
                    null);
            // then
            allTheseMembershipsAreReturned(result, "Membership(10001, First GmbH, 10001, [2022-10-01,), NONE)");
        }
        @Test
        public void normalUser_canViewOnlyRelatedMemberships() {
            // given:
            context("person-FirstGmbH@example.com");
            final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0);
            // when:
            final var result = membershipRepo.findMembershipsByPartnerUuid(givenPartner.getUuid());
            // then:
            exactlyTheseMembershipsAreReturned(result, "Membership(10001, First GmbH, 10001, [2022-10-01,), NONE)");
        }
    }
    @Nested
    class FindByOptionalMemberNumber {
        @Test
        public void globalAdmin_canViewArbitraryMembership() {
        public void globalAdmin_withoutAssumedRole_canFindAllMembershipByMemberNumber() {
            // given
            context("superuser-alex@hostsharing.net");
            // when
            final var result = membershipRepo.findMembershipByOptionalMemberNumber(10002);
            final var result = membershipRepo.findMembershipsByOptionalPartnerUuidAndOptionalMemberNumber(null, 10002);
            // then
            exactlyTheseMembershipsAreReturned(result, "Membership(10002, Second e.K., 10002, [2022-10-01,), NONE)");
        }
        @Test
        public void debitorAdmin_canViewRelatedMemberships() {
            // given
            // context("person-FirstGmbH@example.com");
            context("superuser-alex@hostsharing.net", "hs_office_partner#FirstGmbH-firstcontact.agent");
            //            context("superuser-alex@hostsharing.net", "hs_office_debitor#10001FirstGmbH-firstcontact.agent");
            //            context("superuser-alex@hostsharing.net", "hs_office_membership#10001FirstGmbH-firstcontact.admin");
            // when
            final var result = membershipRepo.findMembershipByOptionalMemberNumber(null);
            // then
            exactlyTheseMembershipsAreReturned(result, "Membership(10001, First GmbH, 10001, [2022-10-01,), NONE)");
        }
    }