From c862df78465f0028e54c97ac55b4f071c9ec7991 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 18 Oct 2022 13:57:35 +0200 Subject: [PATCH] add hs-office-membership API and controller --- .../HsOfficeMembershipController.java | 154 ++++++ .../hs-office/api-mappings.yaml | 2 + .../hs-office-membership-schemas.yaml | 75 +++ .../hs-office-memberships-with-uuid.yaml | 83 +++ .../hs-office/hs-office-memberships.yaml | 64 +++ .../api-definition/hs-office/hs-office.yaml | 9 + .../db/changelog/300-hs-office-membership.sql | 2 +- ...iceMembershipControllerAcceptanceTest.java | 476 ++++++++++++++++++ ...ceMembershipRepositoryIntegrationTest.java | 58 +-- 9 files changed, 886 insertions(+), 37 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java create mode 100644 src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml create mode 100644 src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml create mode 100644 src/main/resources/api-definition/hs-office/hs-office-memberships.yaml create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java new file mode 100644 index 00000000..2a2c1e79 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java @@ -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> 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 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 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 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 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 toPostgresDateRange( + final LocalDate validFrom, + final LocalDate validTo) { + return validTo != null + ? Range.closedOpen(validFrom, validTo.plusDays(1)) + : Range.closedInfinite(validFrom); + } + + final BiConsumer 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 SEPA_MANDATE_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + entity.setValidity(toPostgresDateRange(resource.getValidFrom(), resource.getValidTo())); + }; +} diff --git a/src/main/resources/api-definition/hs-office/api-mappings.yaml b/src/main/resources/api-definition/hs-office/api-mappings.yaml index cc054e77..af337cdb 100644 --- a/src/main/resources/api-definition/hs-office/api-mappings.yaml +++ b/src/main/resources/api-definition/hs-office/api-mappings.yaml @@ -28,3 +28,5 @@ map: 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 diff --git a/src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml new file mode 100644 index 00000000..e8d6c065 --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-membership-schemas.yaml @@ -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 diff --git a/src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml new file mode 100644 index 00000000..bec6911f --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-memberships-with-uuid.yaml @@ -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' diff --git a/src/main/resources/api-definition/hs-office/hs-office-memberships.yaml b/src/main/resources/api-definition/hs-office/hs-office-memberships.yaml new file mode 100644 index 00000000..d4d60158 --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-memberships.yaml @@ -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' diff --git a/src/main/resources/api-definition/hs-office/hs-office.yaml b/src/main/resources/api-definition/hs-office/hs-office.yaml index f39ae8a0..f6156c86 100644 --- a/src/main/resources/api-definition/hs-office/hs-office.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office.yaml @@ -69,3 +69,12 @@ paths: /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" diff --git a/src/main/resources/db/changelog/300-hs-office-membership.sql b/src/main/resources/db/changelog/300-hs-office-membership.sql index 9c826891..b1b75231 100644 --- a/src/main/resources/db/changelog/300-hs-office-membership.sql +++ b/src/main/resources/db/changelog/300-hs-office-membership.sql @@ -13,7 +13,7 @@ create table if not exists hs_office_membership 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' ); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java new file mode 100644 index 00000000..84470f8a --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java @@ -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(); + }); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java index 5979fa25..72f5569f 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java @@ -169,64 +169,50 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTest { } @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)"); - } } @Nested