Michael Hoennig
2022-10-20 e1895e373529ba79c01dfe3dedbada8c57f547a4
hs-office-partner-details
5 files added
21 files modified
785 ■■■■ changed files
src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java 2 ●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java 15 ●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntity.java 58 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityPatcher.java 32 ●●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java 15 ●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcher.java 16 ●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepository.java 2 ●●● 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-partner-schemas.yaml 52 ●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/050-rbac-base.sql 2 ●●● patch | view | raw | blame | history
src/main/resources/db/changelog/054-rbac-context.sql 2 ●●● patch | view | raw | blame | history
src/main/resources/db/changelog/220-hs-office-partner.sql 28 ●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/223-hs-office-partner-rbac.md 12 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/223-hs-office-partner-rbac.sql 45 ●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/224-hs-office-partner-details-rbac.sql 85 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/228-hs-office-partner-test-data.sql 50 ●●●●● patch | view | raw | blame | history
src/main/resources/db/changelog/db.changelog-master.yaml 2 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityTest.java 4 ●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java 15 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java 3 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java 70 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityPatcherUnitTest.java 93 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityTest.java 51 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java 52 ●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java 74 ●●●●● patch | view | raw | blame | history
src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java 3 ●●●● patch | view | raw | blame | history
src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java
@@ -23,7 +23,7 @@
                JOIN HsOfficePersonEntity person ON person.uuid = partner.person
                JOIN HsOfficeContactEntity contact ON contact.uuid = debitor.billingContact
                WHERE :name is null
                    OR partner.birthName like concat(:name, '%')
                    OR partner.details.birthName like concat(:name, '%')
                    OR person.tradeName like concat(:name, '%')
                    OR person.familyName like concat(:name, '%')
                    OR person.givenName like concat(:name, '%')
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java
@@ -72,6 +72,8 @@
        entityToSave.setPerson(personRepo.findByUuid(body.getPersonUuid()).orElseThrow(
                () -> new NoSuchElementException("cannot find person uuid " + body.getPersonUuid())
        ));
        entityToSave.setDetails(map(body.getDetails(), HsOfficePartnerDetailsEntity.class));
        entityToSave.getDetails().setUuid(UUID.randomUUID());
        final var saved = partnerRepo.save(entityToSave);
@@ -129,13 +131,12 @@
        final var current = partnerRepo.findByUuid(partnerUuid).orElseThrow();
        new HsOfficePartnerEntityPatcher(em, current, contactRepo::findByUuid, personRepo::findByUuid).apply(body);
        new HsOfficePartnerEntityPatcher(em, current).apply(body);
        final var saved = partnerRepo.save(current);
        final var mapped = map(saved, HsOfficePartnerResource.class);
        return ResponseEntity.ok(mapped);
    }
    final BiConsumer<HsOfficePartnerEntity, HsOfficePartnerResource> PARTNER_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
        resource.setPerson(map(entity.getPerson(), HsOfficePersonResource.class));
@@ -145,11 +146,11 @@
    // TODO.impl: user postmapper + getReference
    private HsOfficePartnerEntity mapToHsOfficePartnerEntity(final HsOfficePartnerInsertResource resource) {
        final var entity = new HsOfficePartnerEntity();
        entity.setBirthday(resource.getBirthday());
        entity.setBirthName(resource.getBirthName());
        entity.setDateOfDeath(resource.getDateOfDeath());
        entity.setRegistrationNumber(resource.getRegistrationNumber());
        entity.setRegistrationOffice(resource.getRegistrationOffice());
        //        entity.setBirthday(resource.getBirthday());
        //        entity.setBirthName(resource.getBirthName());
        //        entity.setDateOfDeath(resource.getDateOfDeath());
        //        entity.setRegistrationNumber(resource.getRegistrationNumber());
        //        entity.setRegistrationOffice(resource.getRegistrationOffice());
        return entity;
    }
}
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntity.java
New file
@@ -0,0 +1,58 @@
package net.hostsharing.hsadminng.hs.office.partner;
import lombok.*;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
import net.hostsharing.hsadminng.errors.DisplayName;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.LocalDate;
import java.util.UUID;
import static net.hostsharing.hsadminng.Stringify.stringify;
@Entity
@Table(name = "hs_office_partner_details_rv")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DisplayName("PartnerDetails")
public class HsOfficePartnerDetailsEntity implements Stringifyable {
    private static Stringify<HsOfficePartnerDetailsEntity> stringify = stringify(
            HsOfficePartnerDetailsEntity.class,
            "partnerDetails")
            .withProp(HsOfficePartnerDetailsEntity::getRegistrationOffice)
            .withProp(HsOfficePartnerDetailsEntity::getRegistrationNumber)
            .withProp(HsOfficePartnerDetailsEntity::getBirthday)
            .withProp(HsOfficePartnerDetailsEntity::getBirthday)
            .withProp(HsOfficePartnerDetailsEntity::getDateOfDeath)
            .withSeparator(", ")
            .quotedValues(false);
    private @Id UUID uuid;
    private @Column(name = "registrationoffice") String registrationOffice;
    private @Column(name = "registrationnumber") String registrationNumber;
    private @Column(name = "birthname") String birthName;
    private @Column(name = "birthday") LocalDate birthday;
    private @Column(name = "dateofdeath") LocalDate dateOfDeath;
    @Override
    public String toString() {
        return stringify.apply(this);
    }
    @Override
    public String toShortString() {
        return registrationNumber != null ? registrationNumber
                : birthName != null ? birthName
                : birthday != null ? birthday.toString()
                : dateOfDeath != null ? dateOfDeath.toString() : "<empty details>";
    }
}
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityPatcher.java
New file
@@ -0,0 +1,32 @@
package net.hostsharing.hsadminng.hs.office.partner;
import net.hostsharing.hsadminng.EntityPatcher;
import net.hostsharing.hsadminng.OptionalFromJson;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerDetailsPatchResource;
import javax.persistence.EntityManager;
import java.util.UUID;
class HsOfficePartnerDetailsEntityPatcher implements EntityPatcher<HsOfficePartnerDetailsPatchResource> {
    private final EntityManager em;
    private final HsOfficePartnerDetailsEntity entity;
    HsOfficePartnerDetailsEntityPatcher(
            final EntityManager em,
            final HsOfficePartnerDetailsEntity entity) {
        this.em = em;
        this.entity = entity;
    }
    @Override
    public void apply(final HsOfficePartnerDetailsPatchResource resource) {
        if (resource != null) {
            OptionalFromJson.of(resource.getRegistrationOffice()).ifPresent(entity::setRegistrationOffice);
            OptionalFromJson.of(resource.getRegistrationNumber()).ifPresent(entity::setRegistrationNumber);
            OptionalFromJson.of(resource.getBirthday()).ifPresent(entity::setBirthday);
            OptionalFromJson.of(resource.getBirthName()).ifPresent(entity::setBirthName);
            OptionalFromJson.of(resource.getDateOfDeath()).ifPresent(entity::setDateOfDeath);
        }
    }
}
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java
@@ -6,6 +6,8 @@
import net.hostsharing.hsadminng.Stringifyable;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import org.hibernate.annotations.NotFound;
import org.hibernate.annotations.NotFoundAction;
import javax.persistence.*;
import java.time.LocalDate;
@@ -32,18 +34,17 @@
    private @Id UUID uuid;
    @ManyToOne
    @JoinColumn(name = "personuuid")
    @JoinColumn(name = "personuuid", nullable = false)
    private HsOfficePersonEntity person;
    @ManyToOne
    @JoinColumn(name = "contactuuid")
    @JoinColumn(name = "contactuuid", nullable = false)
    private HsOfficeContactEntity contact;
    private @Column(name = "registrationoffice") String registrationOffice;
    private @Column(name = "registrationnumber") String registrationNumber;
    private @Column(name = "birthname") String birthName;
    private @Column(name = "birthday") LocalDate birthday;
    private @Column(name = "dateofdeath") LocalDate dateOfDeath;
    @ManyToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.DETACH}, optional = true)
    @JoinColumn(name = "detailsuuid", nullable = true)
    @NotFound(action= NotFoundAction.IGNORE)
    private HsOfficePartnerDetailsEntity details;
    @Override
    public String toString() {
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcher.java
@@ -17,18 +17,11 @@
class HsOfficePartnerEntityPatcher implements EntityPatcher<HsOfficePartnerPatchResource> {
    private final EntityManager em;
    private final HsOfficePartnerEntity entity;
    private final Function<UUID, Optional<HsOfficeContactEntity>> fetchContact;
    private final Function<UUID, Optional<HsOfficePersonEntity>> fetchPerson;
    HsOfficePartnerEntityPatcher(
            final EntityManager em,
            final HsOfficePartnerEntity entity,
            final Function<UUID, Optional<HsOfficeContactEntity>> fetchContact,
            final Function<UUID, Optional<HsOfficePersonEntity>> fetchPerson) {
            final HsOfficePartnerEntity entity) {
        this.em = em;
        this.entity = entity;
        this.fetchContact = fetchContact;
        this.fetchPerson = fetchPerson;
    }
    @Override
@@ -41,11 +34,8 @@
            verifyNotNull(newValue, "person");
            entity.setPerson(em.getReference(HsOfficePersonEntity.class, newValue));
        });
        OptionalFromJson.of(resource.getRegistrationOffice()).ifPresent(entity::setRegistrationOffice);
        OptionalFromJson.of(resource.getRegistrationNumber()).ifPresent(entity::setRegistrationNumber);
        OptionalFromJson.of(resource.getBirthday()).ifPresent(entity::setBirthday);
        OptionalFromJson.of(resource.getBirthName()).ifPresent(entity::setBirthName);
        OptionalFromJson.of(resource.getDateOfDeath()).ifPresent(entity::setDateOfDeath);
        new HsOfficePartnerDetailsEntityPatcher(em, entity.getDetails()).apply(resource.getDetails());
    }
    private void verifyNotNull(final UUID newValue, final String propertyName) {
src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepository.java
@@ -16,7 +16,7 @@
                JOIN HsOfficeContactEntity contact ON contact.uuid = partner.contact
                JOIN HsOfficePersonEntity person ON person.uuid = partner.person
                WHERE :name is null
                    OR partner.birthName like concat(:name, '%')
                    OR partner.details.birthName like concat(:name, '%')
                    OR contact.label like concat(:name, '%')
                    OR person.tradeName like concat(:name, '%')
                    OR person.givenName like concat(:name, '%')
src/main/resources/api-definition/hs-office/api-mappings.yaml
@@ -16,6 +16,8 @@
    paths:
        /api/hs/office/partners/{partnerUUID}:
            null: org.openapitools.jackson.nullable.JsonNullable
        /api/hs/office/partners/{partnerUUID}/details:
            null: org.openapitools.jackson.nullable.JsonNullable
        /api/hs/office/contacts/{contactUUID}:
            null: org.openapitools.jackson.nullable.JsonNullable
        /api/hs/office/persons/{personUUID}:
src/main/resources/api-definition/hs-office/hs-office-partner-schemas.yaml
@@ -13,23 +13,32 @@
                    $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
                contact:
                    $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact'
                details:
                    $ref: '#/components/schemas/HsOfficePartnerDetails'
        HsOfficePartnerDetails:
            type: object
            properties:
                uuid:
                    type: string
                    format: uuid
                registrationOffice:
                   type: string
                   nullable: true
                    type: string
                    nullable: true
                registrationNumber:
                   type: string
                   nullable: true
                    type: string
                    nullable: true
                birthName:
                   type: string
                   nullable: true
                    type: string
                    nullable: true
                birthday:
                   type: string
                   format: date
                   nullable: true
                    type: string
                    format: date
                    nullable: true
                dateOfDeath:
                   type: string
                   format: date
                   nullable: true
                    type: string
                    format: date
                    nullable: true
        HsOfficePartnerPatch:
            type: object
@@ -42,6 +51,13 @@
                    type: string
                    format: uuid
                    nullable: true
                details:
                    $ref: '#/components/schemas/HsOfficePartnerDetailsPatch'
        HsOfficePartnerDetailsPatch:
            type: object
            nullable: true
            properties:
                registrationOffice:
                    type: string
                    nullable: true
@@ -69,6 +85,15 @@
                contactUuid:
                    type: string
                    format: uuid
                details:
                    $ref: '#/components/schemas/HsOfficePartnerDetailsInsert'
            required:
                - personUuid
                - contactUuid
        HsOfficePartnerDetailsInsert:
            type: object
            properties:
                registrationOffice:
                    type: string
                    nullable: true
@@ -86,6 +111,3 @@
                    type: string
                    format: date
                    nullable: true
            required:
              - personUuid
              - contactUuid
src/main/resources/db/changelog/050-rbac-base.sql
@@ -372,7 +372,7 @@
            or VALUE = 'view'
            or VALUE = 'assume'
            or VALUE ~ '^add-[a-z]+$'
            or VALUE ~ '^new-[a-z]+$'
            or VALUE ~ '^new-[a-z-]+$'
        );
create table RbacPermission
src/main/resources/db/changelog/054-rbac-context.sql
@@ -63,7 +63,7 @@
                  and r.roleType = roleTypeToAssume
                into roleUuidToAssume;
            if roleUuidToAssume is null then
                raise exception '[403] role % not accessible for user %', roleName, currentUser();
                raise exception '[403] role % not accessible for user %', roleName, currentSubjects();
            end if;
            if not isGranted(currentUserUuid, roleUuidToAssume) then
                raise exception '[403] user % has no permission to assume role %', currentUser(), roleName;
src/main/resources/db/changelog/220-hs-office-partner.sql
@@ -1,14 +1,13 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-office-partner-MAIN-TABLE:1 endDelimiter:--//
--changeset hs-office-partner-DETAILS-TABLE:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
create table if not exists hs_office_partner
create table hs_office_partner_details
(
    uuid                uuid unique references RbacObject (uuid) initially deferred,
    personUuid          uuid not null references hs_office_person(uuid),
    contactUuid         uuid not null references hs_office_contact(uuid),
    registrationOffice  varchar(96),
    registrationNumber  varchar(96),
    birthName           varchar(96),
@@ -19,6 +18,27 @@
-- ============================================================================
--changeset hs-office-partner-DETAILS-TABLE-JOURNAL:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call create_journal('hs_office_partner_details');
--//
-- ============================================================================
--changeset hs-office-partner-MAIN-TABLE:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
create table hs_office_partner
(
    uuid                uuid unique references RbacObject (uuid) initially deferred,
    personUuid          uuid not null references hs_office_person(uuid),
    contactUuid         uuid not null references hs_office_contact(uuid),
    detailsUuid         uuid not null references hs_office_partner_details(uuid) on delete cascade
);
--//
-- ============================================================================
--changeset hs-office-partner-MAIN-TABLE-JOURNAL:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
src/main/resources/db/changelog/223-hs-office-partner-rbac.md
@@ -27,17 +27,27 @@
    --> role:hsOfficePerson.guest[person.guest]    
end
subgraph hsOfficePartnerDetails
    direction TB
    perm:hsOfficePartnerDetails.*{{partner.*}}
    perm:hsOfficePartnerDetails.edit{{partner.edit}}
    perm:hsOfficePartnerDetails.view{{partner.view}}
end
subgraph hsOfficePartner
                    
   role:hsOfficePartner.owner[partner.owner]
   %% permissions
       role:hsOfficePartner.owner --> perm:hsOfficePartner.*{{partner.*}}
       role:hsOfficePartner.owner --> perm:hsOfficePartnerDetails.*{{partner.*}}
   %% incoming
       role:global.admin ---> role:hsOfficePartner.owner
  
   role:hsOfficePartner.admin[partner.admin]
   %% permissions
       role:hsOfficePartner.admin --> perm:hsOfficePartner.edit{{partner.edit}}
       role:hsOfficePartner.admin --> perm:hsOfficePartnerDetails.edit{{partner.edit}}
   %% incoming
       role:hsOfficePartner.owner ---> role:hsOfficePartner.admin
   %% outgoing
@@ -45,6 +55,8 @@
       role:hsOfficePartner.admin --> role:hsOfficeContact.tenant
  
   role:hsOfficePartner.agent[partner.agent]
   %% permissions
       role:hsOfficePartner.agent --> perm:hsOfficePartnerDetails.view{{partner.view}}
   %% incoming
       role:hsOfficePartner.admin ---> role:hsOfficePartner.agent
       role:hsOfficePerson.admin --> role:hsOfficePartner.agent
src/main/resources/db/changelog/223-hs-office-partner-rbac.sql
@@ -39,6 +39,8 @@
    if TG_OP = 'INSERT' then
        -- === ATTENTION: code generated from related Mermaid flowchart: ===
        perform createRoleWithGrants(
                hsOfficePartnerOwner(NEW),
                permissions => array['*'],
@@ -72,13 +74,39 @@
                    hsOfficeContactGuest(newContact)]
            );
        perform createRoleWithGrants(
                hsOfficePartnerGuest(NEW),
                permissions => array['view'],
                incomingSuperRoles => array[
                    hsOfficePartnerTenant(NEW)]
                incomingSuperRoles => array[hsOfficePartnerTenant(NEW)]
            );
        -- === END of code generated from Mermaid flowchart. ===
        -- Each partner-details entity belong exactly to one partner entity
        -- and it makes little sense just to delegate partner-details roles.
        -- Therefore, we did not model partner-details roles,
        -- but instead just assign extra permissions to existing partner-roles.
        --Attention: Cannot be in partner-details because of insert order (partner is not in database yet)
        call grantPermissionsToRole(
                getRoleId(hsOfficePartnerOwner(NEW), 'fail'),
                createPermissions(NEW.detailsUuid, array ['*'])
            );
        call grantPermissionsToRole(
                getRoleId(hsOfficePartnerAdmin(NEW), 'fail'),
                createPermissions(NEW.detailsUuid, array ['edit'])
            );
        call grantPermissionsToRole(
            -- Yes, here hsOfficePartnerAGENT is used, not hsOfficePartnerTENANT.
            -- Do NOT grant view permission on partner-details to hsOfficePartnerTENANT!
            -- Otherwise package-admins etc. would be able to read the data.
                getRoleId(hsOfficePartnerAgent(NEW), 'fail'),
                createPermissions(NEW.detailsUuid, array ['view'])
            );
    elsif TG_OP = 'UPDATE' then
@@ -87,10 +115,10 @@
            call revokeRoleFromRole(hsOfficePersonTenant(oldPerson), hsOfficePartnerAdmin(OLD));
            call grantRoleToRole(hsOfficePersonTenant(newPerson), hsOfficePartnerAdmin(NEW));
            call revokeRoleFromRole(hsOfficePartnerAgent(OLD), hsOfficePersonAdmin(oldPerson));
            call grantRoleToRole(hsOfficePartnerAgent(NEW), hsOfficePersonAdmin(newPerson));
            call revokeRoleFromRole(hsOfficePersonGuest(oldPerson), hsOfficePartnerTenant(OLD));
            call grantRoleToRole(hsOfficePersonGuest(newPerson), hsOfficePartnerTenant(NEW));
        end if;
@@ -152,12 +180,7 @@
    '(select idName from hs_office_person_iv p where p.uuid = target.personUuid)',
    $updates$
        personUuid = new.personUuid,
        contactUuid = new.contactUuid,
        registrationOffice = new.registrationOffice,
        registrationNumber = new.registrationNumber,
        birthday = new.birthday,
        birthName = new.birthName,
        dateOfDeath = new.dateOfDeath
        contactUuid = new.contactUuid
    $updates$);
--//
src/main/resources/db/changelog/224-hs-office-partner-details-rbac.sql
New file
@@ -0,0 +1,85 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-office-partner-details-rbac-OBJECT:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRelatedRbacObject('hs_office_partner_details');
--//
-- ============================================================================
--changeset hs-office-partner-details-rbac-IDENTITY-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacIdentityView('hs_office_partner_details', $idName$
    (select idName || '-details' from hs_office_partner_iv partner_iv
        join hs_office_partner partner on (partner_iv.uuid = partner.uuid)
        where partner.detailsUuid = target.uuid)
    $idName$);
--//
-- ============================================================================
--changeset hs-office-partner-details-rbac-RESTRICTED-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacRestrictedView('hs_office_partner_details',
    'target.uuid', -- no specific order required
    $updates$
        registrationOffice = new.registrationOffice,
        registrationNumber = new.registrationNumber,
        birthName          = new.birthName,
        birthday           = new.birthday,
        dateOfDeath        = new.dateOfDeath
    $updates$);
--//
-- ============================================================================
--changeset hs-office-partner-details-rbac-NEW-CONTACT:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
    Creates a global permission for new-partner-details and assigns it to the hostsharing admins role.
 */
do language plpgsql $$
    declare
        addCustomerPermissions uuid[];
        globalObjectUuid       uuid;
        globalAdminRoleUuid    uuid ;
    begin
        call defineContext('granting global new-partner-details permission to global admin role', null, null, null);
        globalAdminRoleUuid := findRoleId(globalAdmin());
        globalObjectUuid := (select uuid from global);
        addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-partner-details']);
        call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions);
    end;
$$;
-- TODO.refa: the code below could be moved to a generator, maybe even the code above.
--  Additionally, the code below is not neccesary for all entities, specifiy when it is!
/**
    Used by the trigger to prevent the add-partner-details to current user respectively assumed roles.
 */
create or replace function addHsOfficePartnerDetailsNotAllowedForCurrentSubjects()
    returns trigger
    language PLPGSQL
as $$
begin
    raise exception '[403] new-partner-details not permitted for %',
        array_to_string(currentSubjects(), ';', 'null');
end; $$;
/**
    Checks if the user or assumed roles are allowed to create new partner-details.
 */
create trigger hs_office_partner_details_insert_trigger
    before insert
    on hs_office_partner_details
    for each row
    when ( not hasAssumedRole() )
execute procedure addHsOfficePartnerDetailsNotAllowedForCurrentSubjects();
--//
src/main/resources/db/changelog/228-hs-office-partner-test-data.sql
@@ -11,11 +11,12 @@
create or replace procedure createHsOfficePartnerTestData( personTradeOrFamilyName varchar, contactLabel varchar )
    language plpgsql as $$
declare
    currentTask     varchar;
    idName          varchar;
    relatedPerson   hs_office_person;
    relatedContact  hs_office_contact;
    birthday        date;
    currentTask         varchar;
    idName              varchar;
    relatedPerson       hs_office_person;
    relatedContact      hs_office_contact;
    relatedDetailsUuid  uuid;
    birthday            date;
begin
    idName := cleanIdentifier( personTradeOrFamilyName|| '-' || contactLabel);
    currentTask := 'creating partner test-data ' || idName;
@@ -36,34 +37,25 @@
    raise notice 'creating test partner: %', idName;
    raise notice '- using person (%): %', relatedPerson.uuid, relatedPerson;
    raise notice '- using contact (%): %', relatedContact.uuid, relatedContact;
    if relatedPerson.persontype = 'NATURAL' then
        insert
            into hs_office_partner_details (uuid, birthName, birthday)
            values (uuid_generate_v4(), 'Meyer', '1987-10-31')
            returning uuid into relatedDetailsUuid;
    else
        insert
            into hs_office_partner_details (uuid, registrationOffice, registrationNumber)
            values (uuid_generate_v4(), 'Hamburg', '12345')
            returning uuid into relatedDetailsUuid;
    end if;
    insert
        into hs_office_partner (uuid, personuuid, contactuuid, birthday)
        values (uuid_generate_v4(), relatedPerson.uuid, relatedContact.uuid, birthDay);
        into hs_office_partner (uuid, personuuid, contactuuid, detailsUuid)
        values (uuid_generate_v4(), relatedPerson.uuid, relatedContact.uuid, relatedDetailsUuid);
end; $$;
--//
/*
    Creates a range of test partner for mass data generation.
 */
create or replace procedure createHsOfficePartnerTestData(
    startCount integer,  -- count of auto generated rows before the run
    endCount integer     -- count of auto generated rows after the run
)
    language plpgsql as $$
declare
    person hs_office_person;
    contact hs_office_contact;
begin
    for t in startCount..endCount
        loop
            select p.* from hs_office_person p where tradeName = intToVarChar(t, 4) into person;
            select c.* from hs_office_contact c where c.label = intToVarChar(t, 4) || '#' || t into contact;
            call createHsOfficePartnerTestData(person.uuid, contact.uuid);
            commit;
        end loop;
end; $$;
--//
-- ============================================================================
src/main/resources/db/changelog/db.changelog-master.yaml
@@ -66,6 +66,8 @@
    - include:
        file: db/changelog/223-hs-office-partner-rbac.sql
    - include:
        file: db/changelog/224-hs-office-partner-details-rbac.sql
    - include:
        file: db/changelog/228-hs-office-partner-test-data.sql
    - include:
        file: db/changelog/230-hs-office-relationship.sql
src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityTest.java
@@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerDetailsEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import org.junit.jupiter.api.Test;
@@ -17,7 +18,8 @@
                        .person(HsOfficePersonEntity.builder()
                                .tradeName("some trade name")
                                .build())
                        .birthName("some birth name")
                        .details(HsOfficePartnerDetailsEntity.builder().birthName("some birth name").build())
                        .build())
                .billingContact(HsOfficeContactEntity.builder().label("some label").build())
                .build();
src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java
@@ -66,7 +66,7 @@
    class CreateDebitor {
        @Test
        public void testHostsharingAdmin_withoutAssumedRole_canCreateNewDebitor() {
        public void globalAdmin_canCreateNewDebitor() {
            // given
            context("superuser-alex@hostsharing.net");
            final var count = debitorRepo.count();
@@ -170,7 +170,7 @@
    class FindByOptionalName {
        @Test
        public void globalAdmin_withoutAssumedRole_canViewAllDebitors() {
        public void globalAdmin_canViewAllDebitors() {
            // given
            context("superuser-alex@hostsharing.net");
@@ -219,7 +219,7 @@
    class FindByDebitorNumberLike {
        @Test
        public void globalAdmin_withoutAssumedRole_canViewAllDebitors() {
        public void globalAdmin_canViewAllDebitors() {
            // given
            context("superuser-alex@hostsharing.net");
@@ -235,7 +235,7 @@
    class FindByNameLike {
        @Test
        public void globalAdmin_withoutAssumedRole_canViewAllDebitors() {
        public void globalAdmin_canViewAllDebitors() {
            // given
            context("superuser-alex@hostsharing.net");
@@ -251,7 +251,7 @@
    class UpdateDebitor {
        @Test
        public void hostsharingAdmin_withoutAssumedRole_canUpdateArbitraryDebitor() {
        public void globalAdmin_canUpdateArbitraryDebitor() {
            // given
            context("superuser-alex@hostsharing.net");
            final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "fifth contact");
@@ -336,7 +336,8 @@
        private void assertThatDebitorActuallyInDatabase(final HsOfficeDebitorEntity saved) {
            final var found = debitorRepo.findByUuid(saved.getUuid());
            assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved);
            assertThat(found).isNotEmpty().get().isNotSameAs(saved)
                    .extracting(Object::toString).isEqualTo(saved.toString());
        }
        private void assertThatDebitorIsVisibleForUserWithRole(
@@ -363,7 +364,7 @@
    class DeleteByUuid {
        @Test
        public void globalAdmin_withoutAssumedRole_canDeleteAnyDebitor() {
        public void globalAdmin_canDeleteAnyDebitor() {
            // given
            context("superuser-alex@hostsharing.net", null);
            final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "tenth");
src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java
@@ -271,7 +271,8 @@
        private void assertThatMembershipExistsAndIsAccessibleToCurrentContext(final HsOfficeMembershipEntity saved) {
            final var found = membershipRepo.findByUuid(saved.getUuid());
            assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved);
            assertThat(found).isNotEmpty().get().isNotSameAs(saved)
                    .extracting(Object::toString).isEqualTo(saved.toString());
        }
        private void assertThatMembershipIsVisibleForUserWithRole(
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java
@@ -79,23 +79,27 @@
                        {
                            "person": { "familyName": "Smith" },
                            "contact": { "label": "fifth contact" },
                            "birthday": "1987-10-31"
                            "details": { "birthday": "1987-10-31" }
                        },
                        {
                            "person": { "tradeName": "First GmbH" },
                            "contact": { "label": "first contact" }
                            "contact": { "label": "first contact" },
                            "details": { "registrationOffice": "Hamburg" }
                        },
                        {
                            "person": { "tradeName": "Third OHG" },
                            "contact": { "label": "third contact" }
                            "contact": { "label": "third contact" },
                            "details": { "registrationOffice": "Hamburg" }
                        },
                        {
                            "person": { "tradeName": "Second e.K." },
                            "contact": { "label": "second contact" }
                            "contact": { "label": "second contact" },
                            "details": { "registrationOffice": "Hamburg" }
                        },
                        {
                            "person": { "personType": "SOLE_REPRESENTATION" },
                            "contact": { "label": "forth contact" }
                            "contact": { "label": "forth contact" },
                            "details": { "registrationOffice": "Hamburg" }
                        }
                    ]
                    """));
@@ -122,8 +126,10 @@
                               {
                                   "contactUuid": "%s",
                                   "personUuid": "%s",
                                   "registrationOffice": "Registergericht Hamburg",
                                   "registrationNumber": "123456"
                                   "details": {
                                       "registrationOffice": "Registergericht Aurich",
                                       "registrationNumber": "123456"
                                   }
                                 }
                            """.formatted(givenContact.getUuid(), givenPerson.getUuid()))
                        .port(port)
@@ -133,7 +139,8 @@
                        .statusCode(201)
                        .contentType(ContentType.JSON)
                        .body("uuid", isUuidValid())
                        .body("registrationNumber", is("123456"))
                        .body("details.registrationOffice", is("Registergericht Aurich"))
                        .body("details.registrationNumber", is("123456"))
                        .body("contact.label", is(givenContact.getLabel()))
                        .body("person.tradeName", is(givenPerson.getTradeName()))
                        .header("Location", startsWith("http://localhost"))
@@ -289,11 +296,13 @@
                               {
                                   "contactUuid": "%s",
                                   "personUuid": "%s",
                                   "registrationOffice": "Registergericht Hamburg",
                                   "registrationNumber": "222222",
                                   "birthName": "Maja Schmidt",
                                   "birthday": "1938-04-08",
                                   "dateOfDeath": "2022-01-12"
                                   "details": {
                                       "registrationOffice": "Registergericht Hamburg",
                                       "registrationNumber": "222222",
                                       "birthName": "Maja Schmidt",
                                       "birthday": "1938-04-08",
                                       "dateOfDeath": "2022-01-12"
                                   }
                                 }
                            """.formatted(givenContact.getUuid(), givenPerson.getUuid()))
                    .port(port)
@@ -303,7 +312,7 @@
                    .statusCode(200)
                    .contentType(ContentType.JSON)
                    .body("uuid", isUuidValid())
                    .body("registrationNumber", is("222222"))
                    .body("details.registrationNumber", is("222222"))
                    .body("contact.label", is(givenContact.getLabel()))
                    .body("person.tradeName", is(givenPerson.getTradeName()));
                // @formatter:on
@@ -314,11 +323,11 @@
                    .matches(person -> {
                        assertThat(person.getPerson().getTradeName()).isEqualTo("Third OHG");
                        assertThat(person.getContact().getLabel()).isEqualTo("forth contact");
                        assertThat(person.getRegistrationOffice()).isEqualTo("Registergericht Hamburg");
                        assertThat(person.getRegistrationNumber()).isEqualTo("222222");
                        assertThat(person.getBirthName()).isEqualTo("Maja Schmidt");
                        assertThat(person.getBirthday()).isEqualTo("1938-04-08");
                        assertThat(person.getDateOfDeath()).isEqualTo("2022-01-12");
                        assertThat(person.getDetails().getRegistrationOffice()).isEqualTo("Registergericht Hamburg");
                        assertThat(person.getDetails().getRegistrationNumber()).isEqualTo("222222");
                        assertThat(person.getDetails().getBirthName()).isEqualTo("Maja Schmidt");
                        assertThat(person.getDetails().getBirthday()).isEqualTo("1938-04-08");
                        assertThat(person.getDetails().getDateOfDeath()).isEqualTo("2022-01-12");
                        return true;
                    });
        }
@@ -335,9 +344,11 @@
                    .contentType(ContentType.JSON)
                    .body("""
                               {
                                   "birthName": "Maja Schmidt",
                                   "birthday": "1938-04-08",
                                   "dateOfDeath": "2022-01-12"
                                    "details": {
                                       "birthName": "Maja Schmidt",
                                       "birthday": "1938-04-08",
                                       "dateOfDeath": "2022-01-12"
                                    }
                                 }
                            """)
                    .port(port)
@@ -347,7 +358,7 @@
                    .statusCode(200)
                    .contentType(ContentType.JSON)
                    .body("uuid", isUuidValid())
                    .body("birthName", is("Maja Schmidt"))
                    .body("details.birthName", is("Maja Schmidt"))
                    .body("contact.label", is(givenPartner.getContact().getLabel()))
                    .body("person.tradeName", is(givenPartner.getPerson().getTradeName()));
            // @formatter:on
@@ -357,11 +368,11 @@
                    .matches(person -> {
                        assertThat(person.getPerson().getTradeName()).isEqualTo(givenPartner.getPerson().getTradeName());
                        assertThat(person.getContact().getLabel()).isEqualTo(givenPartner.getContact().getLabel());
                        assertThat(person.getRegistrationOffice()).isEqualTo(null);
                        assertThat(person.getRegistrationNumber()).isEqualTo(null);
                        assertThat(person.getBirthName()).isEqualTo("Maja Schmidt");
                        assertThat(person.getBirthday()).isEqualTo("1938-04-08");
                        assertThat(person.getDateOfDeath()).isEqualTo("2022-01-12");
                        assertThat(person.getDetails().getRegistrationOffice()).isEqualTo(null);
                        assertThat(person.getDetails().getRegistrationNumber()).isEqualTo(null);
                        assertThat(person.getDetails().getBirthName()).isEqualTo("Maja Schmidt");
                        assertThat(person.getDetails().getBirthday()).isEqualTo("1938-04-08");
                        assertThat(person.getDetails().getDateOfDeath()).isEqualTo("2022-01-12");
                        return true;
                    });
        }
@@ -440,6 +451,9 @@
                    .uuid(UUID.randomUUID())
                    .person(givenPerson)
                    .contact(givenContact)
                    .details(HsOfficePartnerDetailsEntity.builder()
                            .uuid((UUID.randomUUID()))
                            .build())
                    .build();
            toCleanup(newPartner.getUuid());
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityPatcherUnitTest.java
New file
@@ -0,0 +1,93 @@
package net.hostsharing.hsadminng.hs.office.partner;
import net.hostsharing.hsadminng.PatchUnitTestBase;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerDetailsPatchResource;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import javax.persistence.EntityManager;
import java.time.LocalDate;
import java.util.UUID;
import java.util.stream.Stream;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
@TestInstance(PER_CLASS)
@ExtendWith(MockitoExtension.class)
class HsOfficePartnerDetailsEntityPatcherUnitTest extends PatchUnitTestBase<
        HsOfficePartnerDetailsPatchResource,
        HsOfficePartnerDetailsEntity
        > {
    private static final UUID INITIAL_PARTNER_UUID = UUID.randomUUID();
    private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID();
    private static final UUID INITIAL_PERSON_UUID = UUID.randomUUID();
    private static final LocalDate INITIAL_BIRTHDAY = LocalDate.parse("1900-01-01");
    private static final LocalDate PATCHED_BIRTHDAY = LocalDate.parse("1990-12-31");
    private static final LocalDate INITIAL_DAY_OF_DEATH = LocalDate.parse("2000-01-01");
    private static final LocalDate PATCHED_DATE_OF_DEATH = LocalDate.parse("2022-08-31");
    @Mock
    private EntityManager em;
    @BeforeEach
    void initMocks() {
        lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation ->
                HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build());
        lenient().when(em.getReference(eq(HsOfficePersonEntity.class), any())).thenAnswer(invocation ->
                HsOfficePersonEntity.builder().uuid(invocation.getArgument(1)).build());
    }
    @Override
    protected HsOfficePartnerDetailsEntity newInitialEntity() {
        final var entity = new HsOfficePartnerDetailsEntity();
        entity.setUuid(INITIAL_PARTNER_UUID);
        entity.setRegistrationOffice("initial Reg-Office");
        entity.setRegistrationNumber("initial Reg-Number");
        entity.setBirthday(INITIAL_BIRTHDAY);
        entity.setBirthName("initial birth name");
        entity.setDateOfDeath(INITIAL_DAY_OF_DEATH);
        return entity;
    }
    @Override
    protected HsOfficePartnerDetailsPatchResource newPatchResource() {
        return new HsOfficePartnerDetailsPatchResource();
    }
    @Override
    protected HsOfficePartnerDetailsEntityPatcher createPatcher(final HsOfficePartnerDetailsEntity details) {
        return new HsOfficePartnerDetailsEntityPatcher(em, details);
    }
    @Override
    protected Stream<Property> propertyTestDescriptors() {
        return Stream.of(
                new JsonNullableProperty<>(
                        "registrationOffice",
                        HsOfficePartnerDetailsPatchResource::setRegistrationOffice,
                        "patched Reg-Office",
                        HsOfficePartnerDetailsEntity::setRegistrationOffice),
                new JsonNullableProperty<>(
                        "birthday",
                        HsOfficePartnerDetailsPatchResource::setBirthday,
                        PATCHED_BIRTHDAY,
                        HsOfficePartnerDetailsEntity::setBirthday),
                new JsonNullableProperty<>(
                        "dayOfDeath",
                        HsOfficePartnerDetailsPatchResource::setDateOfDeath,
                        PATCHED_DATE_OF_DEATH,
                        HsOfficePartnerDetailsEntity::setDateOfDeath)
        );
    }
}
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerDetailsEntityTest.java
New file
@@ -0,0 +1,51 @@
package net.hostsharing.hsadminng.hs.office.partner;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
class HsOfficePartnerDetailsEntityTest {
    final HsOfficePartnerDetailsEntity given = HsOfficePartnerDetailsEntity.builder()
            .registrationOffice("Hamburg")
            .registrationNumber("12345")
            .birthday(LocalDate.parse("2002-01-15"))
            .birthName("Melly Miller")
            .dateOfDeath(LocalDate.parse("2081-12-21"))
            .build();
    @Test
    void toStringContainsAllProperties() {
        final var result = given.toString();
        assertThat(result).isEqualTo("partnerDetails(Hamburg, 12345, 2002-01-15, 2002-01-15, 2081-12-21)");
    }
    @Test
    void toShortStringContainsFirstNonNullValue() {
        assertThat(given.toShortString()).isEqualTo("12345");
        assertThat(HsOfficePartnerDetailsEntity.builder()
                .birthName("Melly Miller")
                .birthday(LocalDate.parse("2002-01-15"))
                .dateOfDeath(LocalDate.parse("2081-12-21"))
                .build().toShortString()).isEqualTo("Melly Miller");
        assertThat(HsOfficePartnerDetailsEntity.builder()
                .birthday(LocalDate.parse("2002-01-15"))
                .dateOfDeath(LocalDate.parse("2081-12-21"))
                .build().toShortString()).isEqualTo("2002-01-15");
        assertThat(HsOfficePartnerDetailsEntity.builder()
                .dateOfDeath(LocalDate.parse("2081-12-21"))
                .build().toShortString()).isEqualTo("2081-12-21");
        assertThat(HsOfficePartnerDetailsEntity.builder()
                .build().toShortString()).isEqualTo("<empty details>");
    }
}
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java
@@ -1,9 +1,9 @@
package net.hostsharing.hsadminng.hs.office.partner;
import net.hostsharing.hsadminng.PatchUnitTestBase;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import net.hostsharing.hsadminng.PatchUnitTestBase;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -11,14 +11,12 @@
import org.mockito.junit.jupiter.MockitoExtension;
import javax.persistence.EntityManager;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
import static org.mockito.Mockito.lenient;
@TestInstance(PER_CLASS)
@@ -31,14 +29,9 @@
    private static final UUID INITIAL_PARTNER_UUID = UUID.randomUUID();
    private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID();
    private static final UUID INITIAL_PERSON_UUID = UUID.randomUUID();
    private static final UUID INITIAL_DETAILS_UUID = UUID.randomUUID();
    private static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID();
    private static final UUID PATCHED_PERSON_UUID = UUID.randomUUID();
    private static final LocalDate INITIAL_BIRTHDAY = LocalDate.parse("1900-01-01");
    private static final LocalDate PATCHED_BIRTHDAY = LocalDate.parse("1990-12-31");
    private static final LocalDate INITIAL_DAY_OF_DEATH = LocalDate.parse("2000-01-01");
    private static final LocalDate PATCHED_DATE_OF_DEATH = LocalDate.parse("2022-08-31");
    private final HsOfficePersonEntity givenInitialPerson = HsOfficePersonEntity.builder()
            .uuid(INITIAL_PERSON_UUID)
@@ -46,13 +39,17 @@
    private final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder()
            .uuid(INITIAL_CONTACT_UUID)
            .build();
    private final HsOfficePartnerDetailsEntity givenInitialDetails = HsOfficePartnerDetailsEntity.builder()
            .uuid(INITIAL_DETAILS_UUID)
            .build();
    @Mock
    private EntityManager em;
    @BeforeEach
    void initMocks() {
        lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation ->
            HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build());
                HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build());
        lenient().when(em.getReference(eq(HsOfficePersonEntity.class), any())).thenAnswer(invocation ->
                HsOfficePersonEntity.builder().uuid(invocation.getArgument(1)).build());
    }
@@ -63,11 +60,7 @@
        entity.setUuid(INITIAL_PARTNER_UUID);
        entity.setPerson(givenInitialPerson);
        entity.setContact(givenInitialContact);
        entity.setRegistrationOffice("initial Reg-Office");
        entity.setRegistrationNumber("initial Reg-Number");
        entity.setBirthday(INITIAL_BIRTHDAY);
        entity.setBirthName("initial birth name");
        entity.setDateOfDeath(INITIAL_DAY_OF_DEATH);
        entity.setDetails(givenInitialDetails);
        return entity;
    }
@@ -78,15 +71,7 @@
    @Override
    protected HsOfficePartnerEntityPatcher createPatcher(final HsOfficePartnerEntity partner) {
        return new HsOfficePartnerEntityPatcher(
                em,
                partner,
                uuid -> uuid == PATCHED_CONTACT_UUID
                        ? Optional.of(newContact(uuid))
                        : Optional.empty(),
                uuid -> uuid == PATCHED_PERSON_UUID
                        ? Optional.of(newPerson(uuid))
                        : Optional.empty());
        return new HsOfficePartnerEntityPatcher(em, partner);
    }
    @Override
@@ -105,22 +90,7 @@
                        PATCHED_PERSON_UUID,
                        HsOfficePartnerEntity::setPerson,
                        newPerson(PATCHED_PERSON_UUID))
                        .notNullable(),
                new JsonNullableProperty<>(
                        "registrationOffice",
                        HsOfficePartnerPatchResource::setRegistrationOffice,
                        "patched Reg-Office",
                        HsOfficePartnerEntity::setRegistrationOffice),
                new JsonNullableProperty<>(
                        "birthday",
                        HsOfficePartnerPatchResource::setBirthday,
                        PATCHED_BIRTHDAY,
                        HsOfficePartnerEntity::setBirthday),
                new JsonNullableProperty<>(
                        "dayOfDeath",
                        HsOfficePartnerPatchResource::setDateOfDeath,
                        PATCHED_DATE_OF_DEATH,
                        HsOfficePartnerEntity::setDateOfDeath)
                        .notNullable()
        );
    }
src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java
@@ -20,7 +20,6 @@
import javax.persistence.EntityManager;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDate;
import java.util.*;
import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf;
@@ -77,6 +76,9 @@
                        .uuid(UUID.randomUUID())
                        .person(givenPerson)
                        .contact(givenContact)
                        .details(HsOfficePartnerDetailsEntity.builder()
                                .uuid(UUID.randomUUID())
                                .build())
                        .build());
                return partnerRepo.save(newPartner);
            });
@@ -107,6 +109,7 @@
                        .uuid(UUID.randomUUID())
                        .person(givenPerson)
                        .contact(givenContact)
                        .details(HsOfficePartnerDetailsEntity.builder().uuid(UUID.randomUUID()).build())
                        .build());
                return partnerRepo.save(newPartner);
            });
@@ -126,28 +129,32 @@
                    .containsExactlyInAnyOrder(Array.fromFormatted(
                            initialGrantNames,
                            // owner
                            "{ grant perm * on partner#EBess-4th    to role partner#EBess-4th.owner     by system and assume }",
                            "{ grant role partner#EBess-4th.owner   to role global#global.admin         by system and assume }",
                            "{ grant perm * on partner#EBess-4th                    to role partner#EBess-4th.owner     by system and assume }",
                            "{ grant perm * on partner_details#EBess-4th-details    to role partner#EBess-4th.owner     by system and assume }",
                            "{ grant role partner#EBess-4th.owner                   to role global#global.admin         by system and assume }",
                            // admin
                            "{ grant perm edit on partner#EBess-4th to role partner#EBess-4th.admin     by system and assume }",
                            "{ grant role partner#EBess-4th.admin   to role partner#EBess-4th.owner     by system and assume }",
                            "{ grant role person#EBess.tenant       to role partner#EBess-4th.admin     by system and assume }",
                            "{ grant role contact#4th.tenant        to role partner#EBess-4th.admin     by system and assume }",
                            "{ grant perm edit on partner#EBess-4th                 to role partner#EBess-4th.admin     by system and assume }",
                            "{ grant perm edit on partner_details#EBess-4th-details to role partner#EBess-4th.admin     by system and assume }",
                            "{ grant role partner#EBess-4th.admin                   to role partner#EBess-4th.owner     by system and assume }",
                            "{ grant role person#EBess.tenant                       to role partner#EBess-4th.admin     by system and assume }",
                            "{ grant role contact#4th.tenant                        to role partner#EBess-4th.admin     by system and assume }",
                            // agent
                            "{ grant role partner#EBess-4th.agent   to role partner#EBess-4th.admin     by system and assume }",
                            "{ grant role partner#EBess-4th.agent   to role person#EBess.admin          by system and assume }",
                            "{ grant role partner#EBess-4th.agent   to role contact#4th.admin           by system and assume }",
                            "{ grant perm view on partner_details#EBess-4th-details to role partner#EBess-4th.agent     by system and assume }",
                            "{ grant role partner#EBess-4th.agent                   to role partner#EBess-4th.admin     by system and assume }",
                            "{ grant role partner#EBess-4th.agent                   to role person#EBess.admin          by system and assume }",
                            "{ grant role partner#EBess-4th.agent                   to role contact#4th.admin           by system and assume }",
                            // tenant
                            "{ grant role partner#EBess-4th.tenant  to role partner#EBess-4th.agent     by system and assume }",
                            "{ grant role person#EBess.guest        to role partner#EBess-4th.tenant    by system and assume }",
                            "{ grant role contact#4th.guest         to role partner#EBess-4th.tenant    by system and assume }",
                            "{ grant role partner#EBess-4th.tenant                  to role partner#EBess-4th.agent     by system and assume }",
                            "{ grant role person#EBess.guest                        to role partner#EBess-4th.tenant    by system and assume }",
                            "{ grant role contact#4th.guest                         to role partner#EBess-4th.tenant    by system and assume }",
                            // guest
                            "{ grant perm view on partner#EBess-4th to role partner#EBess-4th.guest     by system and assume }",
                            "{ grant role partner#EBess-4th.guest   to role partner#EBess-4th.tenant    by system and assume }",
                            "{ grant perm view on partner#EBess-4th                 to role partner#EBess-4th.guest     by system and assume }",
                            "{ grant role partner#EBess-4th.guest                   to role partner#EBess-4th.tenant    by system and assume }",
                            null));
        }
@@ -226,7 +233,6 @@
                context("superuser-alex@hostsharing.net");
                givenPartner.setContact(givenNewContact);
                givenPartner.setPerson(givenNewPerson);
                givenPartner.setDateOfDeath(LocalDate.parse("2022-09-15"));
                return toCleanup(partnerRepo.save(givenPartner));
            });
@@ -246,47 +252,26 @@
        }
        @Test
        public void personAdmin_canNotUpdateRelatedPartner() {
            // given
            context("superuser-alex@hostsharing.net");
            final var givenPartner = givenSomeTemporaryPartnerBessler("eighth");
            assertThatPartnerIsVisibleForUserWithRole(
                    givenPartner,
                    "hs_office_person#ErbenBesslerMelBessler.admin");
            assertThatPartnerActuallyInDatabase(givenPartner);
            // when
            final var result = jpaAttempt.transacted(() -> {
                context("superuser-alex@hostsharing.net", "hs_office_person#ErbenBesslerMelBessler.admin");
                givenPartner.setDateOfDeath(LocalDate.parse("2022-09-15"));
                return partnerRepo.save(givenPartner);
            });
            // then
            result.assertExceptionWithRootCauseMessage(JpaSystemException.class,
                    "[403] Subject ", " is not allowed to update hs_office_partner uuid");
        }
        @Test
        public void contactAdmin_canNotUpdateRelatedPartner() {
        public void partnerAgent_canNotUpdateRelatedPartner() {
            // given
            context("superuser-alex@hostsharing.net");
            final var givenPartner = givenSomeTemporaryPartnerBessler("ninth");
            assertThatPartnerIsVisibleForUserWithRole(
                    givenPartner,
                    "hs_office_contact#ninthcontact.admin");
                    "hs_office_partner#ErbenBesslerMelBessler-ninthcontact.agent");
            assertThatPartnerActuallyInDatabase(givenPartner);
            final var givenNewContact = contactRepo.findContactByOptionalLabelLike("tenth").get(0);
            // when
            final var result = jpaAttempt.transacted(() -> {
                context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact.admin");
                givenPartner.setDateOfDeath(LocalDate.parse("2022-09-15"));
                context("superuser-alex@hostsharing.net", "hs_office_partner#ErbenBesslerMelBessler-ninthcontact.agent");
                givenPartner.getDetails().setBirthName("new birthname");
                return partnerRepo.save(givenPartner);
            });
            // then
            result.assertExceptionWithRootCauseMessage(JpaSystemException.class,
                    "[403] Subject ", " is not allowed to update hs_office_partner uuid");
                    "[403] Subject ", " is not allowed to update hs_office_partner_details uuid");
        }
        private void assertThatPartnerActuallyInDatabase(final HsOfficePartnerEntity saved) {
@@ -424,6 +409,9 @@
                    .uuid(UUID.randomUUID())
                    .person(givenPerson)
                    .contact(givenContact)
                    .details(HsOfficePartnerDetailsEntity.builder()
                            .uuid(UUID.randomUUID())
                            .build())
                    .build();
            toCleanup(newPartner);
src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java
@@ -290,7 +290,8 @@
        private void assertThatSepaMandateActuallyInDatabase(final HsOfficeSepaMandateEntity saved) {
            final var found = sepaMandateRepo.findByUuid(saved.getUuid());
            assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved);
            assertThat(found).isNotEmpty().get().isNotSameAs(saved)
                    .extracting(Object::toString).isEqualTo(saved.toString());
        }
        private void assertThatSepaMandateIsVisibleForUserWithRole(