1
0

update dependend relations when updating partner person (#162)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/162
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-03-10 12:04:54 +01:00
parent e3b11972e5
commit a2b81f009b
26 changed files with 949 additions and 241 deletions

View File

@ -13,15 +13,13 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.annotation.PostConstruct;
import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.errors.Validate.validate;
import static net.hostsharing.hsadminng.mapper.KeyValueMap.from;
@RestController
public class HsOfficeContactController implements HsOfficeContactsApi {
@Autowired
@ -30,9 +28,20 @@ public class HsOfficeContactController implements HsOfficeContactsApi {
@Autowired
private StrictMapper mapper;
@Autowired
private HsOfficeContactFromResourceConverter<HsOfficeContactRbacEntity> contactFromResourceConverter;
@Autowired
private HsOfficeContactRbacRepository contactRepo;
@PostConstruct
public void init() {
// HOWTO: add a ModelMapper converter for a generic entity class to a ModelMapper to be used in a certain context
// This @PostConstruct could be implemented in the converter, but only without generics.
// But this converter is for HsOfficeContactRbacEntity and HsOfficeContactRealEntity.
mapper.addConverter(contactFromResourceConverter, HsOfficeContactInsertResource.class, HsOfficeContactRbacEntity.class);
}
@Override
@Transactional(readOnly = true)
@Timed("app.office.contacts.api.getListOfContacts")
@ -62,7 +71,7 @@ public class HsOfficeContactController implements HsOfficeContactsApi {
context.define(currentSubject, assumedRoles);
final var entityToSave = mapper.map(body, HsOfficeContactRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
final var entityToSave = mapper.map(body, HsOfficeContactRbacEntity.class);
final var saved = contactRepo.save(entityToSave);
@ -128,11 +137,4 @@ public class HsOfficeContactController implements HsOfficeContactsApi {
final var mapped = mapper.map(saved, HsOfficeContactResource.class);
return ResponseEntity.ok(mapped);
}
@SuppressWarnings("unchecked")
final BiConsumer<HsOfficeContactInsertResource, HsOfficeContactRbacEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.putPostalAddress(from(resource.getPostalAddress()));
entity.putEmailAddresses(from(resource.getEmailAddresses()));
entity.putPhoneNumbers(from(resource.getPhoneNumbers()));
};
}

View File

@ -0,0 +1,27 @@
package net.hostsharing.hsadminng.hs.office.contact;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactInsertResource;
import org.modelmapper.Converter;
import org.modelmapper.spi.MappingContext;
import org.springframework.stereotype.Component;
import static net.hostsharing.hsadminng.mapper.KeyValueMap.from;
// HOWTO: implement a ModelMapper converter which converts from a (JSON) resource instance to a generic entity instance (RBAC vs. REAL)
@Component
public class HsOfficeContactFromResourceConverter<E extends HsOfficeContact>
implements Converter<HsOfficeContactInsertResource, E> {
@Override
@SneakyThrows
public E convert(final MappingContext<HsOfficeContactInsertResource, E> context) {
final var resource = context.getSource();
final var entity = context.getDestinationType().getDeclaredConstructor().newInstance();
entity.setCaption(resource.getCaption());
entity.putPostalAddress(from(resource.getPostalAddress()));
entity.putEmailAddresses(from(resource.getEmailAddresses()));
entity.putPhoneNumbers(from(resource.getPhoneNumbers()));
return entity;
}
}

View File

@ -3,8 +3,10 @@ package net.hostsharing.hsadminng.hs.office.partner;
import io.micrometer.core.annotation.Timed;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.errors.ReferenceNotFoundException;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactFromResourceConverter;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePartnersApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerResource;
@ -22,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.List;
@ -42,14 +45,22 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
private StrictMapper mapper;
@Autowired
private HsOfficePartnerRbacRepository partnerRepo;
private HsOfficeContactFromResourceConverter<HsOfficeContactRealEntity> contactFromResourceConverter;
@Autowired
private HsOfficeRelationRealRepository relationRepo;
private HsOfficePartnerRbacRepository rbacPartnerRepo;
@Autowired
private HsOfficeRelationRealRepository realRelationRepo;
@PersistenceContext
private EntityManager em;
@PostConstruct
public void init() {
mapper.addConverter(contactFromResourceConverter, HsOfficeContactInsertResource.class, HsOfficeContactRealEntity.class);
}
@Override
@Transactional(readOnly = true)
@Timed("app.office.partners.api.getListOfPartners")
@ -59,7 +70,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
final String name) {
context.define(currentSubject, assumedRoles);
final var entities = partnerRepo.findPartnerByOptionalNameLike(name);
final var entities = rbacPartnerRepo.findPartnerByOptionalNameLike(name);
final var resources = mapper.mapList(entities, HsOfficePartnerResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(resources);
@ -77,7 +88,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
final var entityToSave = createPartnerEntity(body);
final var saved = partnerRepo.save(entityToSave);
final var saved = rbacPartnerRepo.save(entityToSave);
final var uri =
MvcUriComponentsBuilder.fromController(getClass())
@ -98,7 +109,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
context.define(currentSubject, assumedRoles);
final var result = partnerRepo.findByUuid(partnerUuid);
final var result = rbacPartnerRepo.findByUuid(partnerUuid);
if (result.isEmpty()) {
return ResponseEntity.notFound().build();
}
@ -116,7 +127,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
context.define(currentSubject, assumedRoles);
final var result = partnerRepo.findPartnerByPartnerNumber(partnerNumber);
final var result = rbacPartnerRepo.findPartnerByPartnerNumber(partnerNumber);
if (result.isEmpty()) {
return ResponseEntity.notFound().build();
}
@ -133,12 +144,12 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
final UUID partnerUuid) {
context.define(currentSubject, assumedRoles);
final var partnerToDelete = partnerRepo.findByUuid(partnerUuid);
final var partnerToDelete = rbacPartnerRepo.findByUuid(partnerUuid);
if (partnerToDelete.isEmpty()) {
return ResponseEntity.notFound().build();
}
if (partnerRepo.deleteByUuid(partnerUuid) != 1) {
if (rbacPartnerRepo.deleteByUuid(partnerUuid) != 1) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
@ -156,22 +167,55 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
context.define(currentSubject, assumedRoles);
final var current = partnerRepo.findByUuid(partnerUuid).orElseThrow();
final var previousPartnerRel = current.getPartnerRel();
final var current = rbacPartnerRepo.findByUuid(partnerUuid).orElseThrow();
final var previousPartnerPerson = current.getPartnerRel().getHolder();
new HsOfficePartnerEntityPatcher(em, current).apply(body);
new HsOfficePartnerEntityPatcher(mapper, em, current).apply(body);
final var saved = partnerRepo.save(current);
optionallyCreateExPartnerRelation(saved, previousPartnerRel);
final var saved = rbacPartnerRepo.save(current);
optionallyCreateExPartnerRelation(saved, previousPartnerPerson);
optionallyUpdateRelatedRelations(saved, previousPartnerPerson);
final var mapped = mapper.map(saved, HsOfficePartnerResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(mapped);
}
private void optionallyCreateExPartnerRelation(final HsOfficePartnerRbacEntity saved, final HsOfficeRelationRealEntity previousPartnerRel) {
if (!saved.getPartnerRel().getUuid().equals(previousPartnerRel.getUuid())) {
// TODO.impl: we also need to use the new partner-person as the anchor
relationRepo.save(previousPartnerRel.toBuilder().uuid(null).type(EX_PARTNER).build());
private void optionallyCreateExPartnerRelation(final HsOfficePartnerRbacEntity saved, final HsOfficePersonRealEntity previousPartnerPerson) {
final var partnerPersonHasChanged = !saved.getPartnerRel().getHolder().getUuid().equals(previousPartnerPerson.getUuid());
if (partnerPersonHasChanged) {
realRelationRepo.save(saved.getPartnerRel().toBuilder()
.uuid(null)
.type(EX_PARTNER)
.anchor(saved.getPartnerRel().getHolder())
.holder(previousPartnerPerson)
.build());
}
}
private void optionallyUpdateRelatedRelations(final HsOfficePartnerRbacEntity saved, final HsOfficePersonRealEntity previousPartnerPerson) {
final var partnerPersonHasChanged = !saved.getPartnerRel().getHolder().getUuid().equals(previousPartnerPerson.getUuid());
if (partnerPersonHasChanged) {
// self-debitors of the old partner-person become self-debitors of the new partner person
em.createNativeQuery("""
UPDATE hs_office.relation
SET holderUuid = :newPartnerPersonUuid
WHERE type = 'DEBITOR' AND
holderUuid = :oldPartnerPersonUuid AND anchorUuid = :oldPartnerPersonUuid
""")
.setParameter("oldPartnerPersonUuid", previousPartnerPerson.getUuid())
.setParameter("newPartnerPersonUuid", saved.getPartnerRel().getHolder().getUuid())
.executeUpdate();
// re-anchor all relations from the old partner person to the new partner persion
em.createNativeQuery("""
UPDATE hs_office.relation
SET anchorUuid = :newPartnerPersonUuid
WHERE anchorUuid = :oldPartnerPersonUuid
""")
.setParameter("oldPartnerPersonUuid", previousPartnerPerson.getUuid())
.setParameter("newPartnerPersonUuid", saved.getPartnerRel().getHolder().getUuid())
.executeUpdate();
}
}

View File

@ -1,35 +1,36 @@
package net.hostsharing.hsadminng.hs.office.partner;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationPatcher;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import jakarta.persistence.EntityManager;
class HsOfficePartnerEntityPatcher implements EntityPatcher<HsOfficePartnerPatchResource> {
private final StrictMapper mapper;
private final EntityManager em;
private final HsOfficePartnerRbacEntity entity;
HsOfficePartnerEntityPatcher(
final StrictMapper mapper,
final EntityManager em,
final HsOfficePartnerRbacEntity entity) {
this.mapper = mapper;
this.em = em;
this.entity = entity;
}
@Override
public void apply(final HsOfficePartnerPatchResource resource) {
OptionalFromJson.of(resource.getPartnerRelUuid()).ifPresent(newValue -> {
verifyNotNull(newValue, "partnerRel");
entity.setPartnerRel(em.getReference(HsOfficeRelationRealEntity.class, newValue));
});
new HsOfficePartnerDetailsEntityPatcher(em, entity.getDetails()).apply(resource.getDetails());
}
if (resource.getPartnerRel() != null) {
new HsOfficeRelationPatcher(mapper, em, entity.getPartnerRel()).apply(resource.getPartnerRel());
}
private void verifyNotNull(final Object newValue, final String propertyName) {
if (newValue == null) {
throw new IllegalArgumentException("property '" + propertyName + "' must not be null");
if (resource.getDetails() != null) {
new HsOfficePartnerDetailsEntityPatcher(em, entity.getDetails()).apply(resource.getDetails());
}
}
}

View File

@ -163,13 +163,13 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
final String currentSubject,
final String assumedRoles,
final UUID relationUuid,
final HsOfficeRelationPatchResource body) {
final HsOfficeRelationContactPatchResource body) {
context.define(currentSubject, assumedRoles);
final var current = rbacRelationRepo.findByUuid(relationUuid).orElseThrow();
new HsOfficeRelationEntityPatcher(em, current).apply(body);
new HsOfficeRelationEntityContactPatcher(em, current).apply(body);
final var saved = rbacRelationRepo.save(current);
final var mapped = mapper.map(saved, HsOfficeRelationResource.class);

View File

@ -1,25 +1,25 @@
package net.hostsharing.hsadminng.hs.office.relation;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationContactPatchResource;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import jakarta.persistence.EntityManager;
import java.util.UUID;
class HsOfficeRelationEntityPatcher implements EntityPatcher<HsOfficeRelationPatchResource> {
public class HsOfficeRelationEntityContactPatcher implements EntityPatcher<HsOfficeRelationContactPatchResource> {
private final EntityManager em;
private final HsOfficeRelation entity;
HsOfficeRelationEntityPatcher(final EntityManager em, final HsOfficeRelation entity) {
public HsOfficeRelationEntityContactPatcher(final EntityManager em, final HsOfficeRelation entity) {
this.em = em;
this.entity = entity;
}
@Override
public void apply(final HsOfficeRelationPatchResource resource) {
public void apply(final HsOfficeRelationContactPatchResource resource) {
OptionalFromJson.of(resource.getContactUuid()).ifPresent(newValue -> {
verifyNotNull(newValue, "contact");
entity.setContact(em.getReference(HsOfficeContactRealEntity.class, newValue));

View File

@ -0,0 +1,50 @@
package net.hostsharing.hsadminng.hs.office.relation;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationPatchResource;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import jakarta.persistence.EntityManager;
import jakarta.validation.ValidationException;
public class HsOfficeRelationPatcher implements EntityPatcher<HsOfficeRelationPatchResource> {
private final StrictMapper mapper;
private final EntityManager em;
private final HsOfficeRelation entity;
public HsOfficeRelationPatcher(final StrictMapper mapper, final EntityManager em, final HsOfficeRelation entity) {
this.mapper = mapper;
this.em = em;
this.entity = entity;
}
@Override
public void apply(final HsOfficeRelationPatchResource resource) {
if (resource.getHolder() != null && resource.getHolderUuid() != null) {
throw new ValidationException("either \"holder\" or \"holder.uuid\" can be given, not both");
} else {
if (resource.getHolder() != null) {
final var newHolder = mapper.map(resource.getHolder(), HsOfficePersonRealEntity.class);
em.persist(newHolder);
entity.setHolder(newHolder);
} else if (resource.getHolderUuid() != null) {
entity.setHolder(em.getReference(HsOfficePersonRealEntity.class, resource.getHolderUuid().get()));
}
}
if (resource.getContact() != null && resource.getContactUuid() != null) {
throw new ValidationException("either \"contact\" or \"contact.uuid\" can be given, not both");
} else {
if (resource.getContact() != null) {
final var newContact = mapper.map(resource.getContact(), HsOfficeContactRealEntity.class);
em.persist(newContact);
entity.setContact(newContact);
} else if (resource.getContactUuid() != null) {
entity.setContact(em.getReference(HsOfficeContactRealEntity.class, resource.getContactUuid().get()));
}
}
}
}

View File

@ -51,7 +51,7 @@ public class HsOfficeRelationRbacEntity extends HsOfficeRelation {
"""))
.withRestrictedViewOrderBy(SQL.expression(
"(select idName from hs_office.person_iv p where p.uuid = target.holderUuid)"))
.withUpdatableColumns("contactUuid")
.withUpdatableColumns("anchorUuid", "holderUuid", "contactUuid") // BEWARE: additional checks at API-level
.importEntityAlias("anchorPerson", HsOfficePersonRbacEntity.class, usingDefaultCase(),
dependsOnColumn("anchorUuid"),
directlyFetchedByDependsOnColumn(),

View File

@ -48,12 +48,11 @@ components:
HsOfficePartnerPatch:
type: object
properties:
partnerRel.uuid:
type: string
format: uuid
nullable: true
partnerRel:
$ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationPatch'
details:
$ref: '#/components/schemas/HsOfficePartnerDetailsPatch'
additionalProperties: false
HsOfficePartnerDetailsPatch:
type: object

View File

@ -34,7 +34,7 @@ components:
contact:
$ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact'
HsOfficeRelationPatch:
HsOfficeRelationContactPatch:
type: object
properties:
contact.uuid:
@ -42,6 +42,27 @@ components:
format: uuid
nullable: true
HsOfficeRelationPatch:
type: object
properties:
anchor.uuid:
type: string
format: uuid
nullable: true
holder.uuid:
type: string
format: uuid
nullable: true
holder:
$ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert'
contact.uuid:
type: string
format: uuid
nullable: true
contact:
$ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactInsert'
additionalProperties: false
# arbitrary relation with explicit type
HsOfficeRelationInsert:
type: object

View File

@ -44,7 +44,7 @@ patch:
content:
'application/json':
schema:
$ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationPatch'
$ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationContactPatch'
responses:
"200":
description: OK

View File

@ -124,7 +124,9 @@ create or replace procedure hs_office.relation_update_rbac_system(
language plpgsql as $$
begin
if NEW.contactUuid is distinct from OLD.contactUuid then
if NEW.holderUuid is distinct from OLD.holderUuid
or NEW.anchorUuid is distinct from OLD.anchorUuid
or NEW.contactUuid is distinct from OLD.contactUuid then
delete from rbac.grant g where g.grantedbytriggerof = OLD.uuid;
call hs_office.relation_build_rbac_system(NEW);
end if;
@ -248,6 +250,8 @@ call rbac.generateRbacRestrictedView('hs_office.relation',
(select idName from hs_office.person_iv p where p.uuid = target.holderUuid)
$orderBy$,
$updates$
anchorUuid = new.anchorUuid,
holderUuid = new.holderUuid,
contactUuid = new.contactUuid
$updates$);
--//

View File

@ -20,6 +20,7 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.repository.Repository;
import org.springframework.web.bind.annotation.RestController;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.Table;
import java.lang.annotation.Annotation;
@ -420,7 +421,7 @@ public class ArchitectureTest {
if (isGeneratedSpringRepositoryMethod(item, method)) {
continue;
}
if (item.isAnnotatedWith(RestController.class) && !method.getModifiers().contains(PUBLIC)) {
if (!method.getModifiers().contains(PUBLIC) || method.isAnnotatedWith(PostConstruct.class)) {
continue;
}
final var message = String.format(

View File

@ -316,7 +316,7 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu
context.define("superuser-alex@hostsharing.net");
final var givenPartner = givenSomeTemporaryPartnerBessler(20011);
final var givenPartnerRel = givenSomeTemporaryPartnerRel("Winkler", "third contact");
final var newPartnerPerson = personRealRepo.findPersonByOptionalNameLike("Winkler").getFirst();
RestAssured // @formatter:off
.given()
@ -325,7 +325,9 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("""
{
"partnerNumber": "P-20011",
"partnerRel.uuid": "%s",
"partnerRel": {
"holder.uuid": "%s"
},
"details": {
"registrationOffice": "Temp Registergericht Aurich",
"registrationNumber": "222222",
@ -334,7 +336,7 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu
"dateOfDeath": "2022-01-12"
}
}
""".formatted(givenPartnerRel.getUuid()))
""".formatted(newPartnerPerson.getUuid()))
.port(port)
.when()
.patch("http://localhost/api/hs/office/partners/" + givenPartner.getUuid())
@ -348,7 +350,7 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu
"anchor": { "tradeName": "Hostsharing eG" },
"holder": { "familyName": "Winkler" },
"type": "PARTNER",
"contact": { "caption": "third contact" }
"contact": { "caption": "fourth contact" }
},
"details": {
"registrationOffice": "Temp Registergericht Aurich",
@ -368,7 +370,7 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu
.matches(partner -> {
assertThat(partner.getPartnerNumber()).isEqualTo(givenPartner.getPartnerNumber());
assertThat(partner.getPartnerRel().getHolder().getFamilyName()).isEqualTo("Winkler");
assertThat(partner.getPartnerRel().getContact().getCaption()).isEqualTo("third contact");
assertThat(partner.getPartnerRel().getContact().getCaption()).isEqualTo("fourth contact");
assertThat(partner.getDetails().getRegistrationOffice()).isEqualTo("Temp Registergericht Aurich");
assertThat(partner.getDetails().getRegistrationNumber()).isEqualTo("222222");
assertThat(partner.getDetails().getBirthName()).isEqualTo("Maja Schmidt");
@ -379,11 +381,11 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu
}
@Test
void patchingThePartnerRelCreatesExPartnerRel() {
void patchingThePartnerPersonCreatesExPartnerRel() {
context.define("superuser-alex@hostsharing.net");
final var givenPartner = givenSomeTemporaryPartnerBessler(20011);
final var givenPartnerRel = givenSomeTemporaryPartnerRel("Winkler", "third contact");
final var newPartnerPerson = personRealRepo.findPersonByOptionalNameLike("Winkler").getFirst();
RestAssured // @formatter:off
.given()
@ -391,9 +393,11 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu
.contentType(ContentType.JSON)
.body("""
{
"partnerRel.uuid": "%s"
"partnerRel": {
"holder.uuid": "%s"
}
}
""".formatted(givenPartnerRel.getUuid()))
""".formatted(newPartnerPerson.getUuid()))
.port(port)
.when()
.patch("http://localhost/api/hs/office/partners/" + givenPartner.getUuid())
@ -405,16 +409,16 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu
context.define("superuser-alex@hostsharing.net");
assertThat(partnerRepo.findByUuid(givenPartner.getUuid())).isPresent().get()
.matches(partner -> {
assertThat(partner.getPartnerRel().getHolder().getFamilyName()).isEqualTo("Winkler");
assertThat(partner.getPartnerRel().getContact().getCaption()).isEqualTo("third contact");
assertThat(partner.getPartnerRel().getHolder().getFamilyName()).isEqualTo("Winkler"); // updated
assertThat(partner.getPartnerRel().getContact().getCaption()).isEqualTo("fourth contact"); // unchanged
return true;
});
// and an ex-partner-relation got created
final var anchorpartnerPersonUUid = givenPartner.getPartnerRel().getAnchor().getUuid();
assertThat(relationRepo.findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData(anchorpartnerPersonUUid, EX_PARTNER, null, null, null))
final var newPartnerPersonUuid = givenPartner.getPartnerRel().getHolder().getUuid();
assertThat(relationRepo.findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData(newPartnerPersonUuid, EX_PARTNER, null, null, null))
.map(HsOfficeRelation::toShortString)
.contains("rel(anchor='LP Hostsharing eG', type='EX_PARTNER', holder='UF Erben Bessler')");
.contains("rel(anchor='NP Winkler, Paul', type='EX_PARTNER', holder='UF Erben Bessler')");
}
@Test

View File

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.partner;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactFromResourceConverter;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRbacEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
@ -38,7 +39,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(HsOfficePartnerController.class)
@Import({StrictMapper.class, DisableSecurityConfig.class})
@Import({ StrictMapper.class, HsOfficeContactFromResourceConverter.class, DisableSecurityConfig.class})
@ActiveProfiles("test")
class HsOfficePartnerControllerRestTest {
@ -108,8 +109,6 @@ class HsOfficePartnerControllerRestTest {
"holder.uuid": "%s",
"contact.uuid": "%s"
},
"person.uuid": "%s",
"contact.uuid": "%s",
"details": {
"registrationOffice": "Temp Registergericht Aurich",
"registrationNumber": "111111"
@ -118,8 +117,6 @@ class HsOfficePartnerControllerRestTest {
""".formatted(
GIVEN_MANDANTE_UUID,
GIVEN_INVALID_UUID,
GIVEN_CONTACT_UUID,
GIVEN_INVALID_UUID,
GIVEN_CONTACT_UUID))
.accept(MediaType.APPLICATION_JSON))
@ -145,8 +142,6 @@ class HsOfficePartnerControllerRestTest {
"holder.uuid": "%s",
"contact.uuid": "%s"
},
"person.uuid": "%s",
"contact.uuid": "%s",
"details": {
"registrationOffice": "Temp Registergericht Aurich",
"registrationNumber": "111111"
@ -155,8 +150,6 @@ class HsOfficePartnerControllerRestTest {
""".formatted(
GIVEN_MANDANTE_UUID,
GIVEN_PERSON_UUID,
GIVEN_INVALID_UUID,
GIVEN_PERSON_UUID,
GIVEN_INVALID_UUID))
.accept(MediaType.APPLICATION_JSON))

View File

@ -1,20 +1,25 @@
package net.hostsharing.hsadminng.hs.office.partner;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerDetailsPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePartnerPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationPatchResource;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openapitools.jackson.nullable.JsonNullable;
import jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.util.UUID;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@ -22,19 +27,17 @@ import static org.mockito.Mockito.lenient;
@TestInstance(PER_CLASS)
@ExtendWith(MockitoExtension.class)
class HsOfficePartnerEntityPatcherUnitTest extends PatchUnitTestBase<
HsOfficePartnerPatchResource,
HsOfficePartnerRbacEntity
> {
// This test class does not subclass PatchUnitTestBase because it has no directly patchable properties.
// But the factory-structure is kept, so PatchUnitTestBase could easily be plugged back in if needed.
class HsOfficePartnerEntityPatcherUnitTest {
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_PARTNER_PERSON_UUID = UUID.randomUUID();
private static final UUID INITIAL_DETAILS_UUID = UUID.randomUUID();
private static final UUID PATCHED_PARTNER_ROLE_UUID = UUID.randomUUID();
private final HsOfficePersonRealEntity givenInitialPerson = HsOfficePersonRealEntity.builder()
.uuid(INITIAL_PERSON_UUID)
private final HsOfficePersonRealEntity givenInitialPartnerPerson = HsOfficePersonRealEntity.builder()
.uuid(INITIAL_PARTNER_PERSON_UUID)
.build();
private final HsOfficeContactRealEntity givenInitialContact = HsOfficeContactRealEntity.builder()
.uuid(INITIAL_CONTACT_UUID)
@ -43,22 +46,74 @@ class HsOfficePartnerEntityPatcherUnitTest extends PatchUnitTestBase<
private final HsOfficePartnerDetailsEntity givenInitialDetails = HsOfficePartnerDetailsEntity.builder()
.uuid(INITIAL_DETAILS_UUID)
.build();
@Mock
private EntityManager em;
private EntityManagerWrapper emw;
private StrictMapper mapper = new StrictMapper(emw);
@BeforeEach
void initMocks() {
lenient().when(em.getReference(eq(HsOfficeRelationRealEntity.class), any())).thenAnswer(invocation ->
HsOfficeRelationRealEntity.builder().uuid(invocation.getArgument(1)).build());
lenient().when(emw.getReference(eq(HsOfficePersonRealEntity.class), any())).thenAnswer(invocation ->
HsOfficePersonRealEntity.builder().uuid(invocation.getArgument(1)).build());
lenient().when(emw.getReference(eq(HsOfficeContactRealEntity.class), any())).thenAnswer(invocation ->
HsOfficeContactRealEntity.builder().uuid(invocation.getArgument(1)).build());
}
@Test
void patchPartnerPerson() {
// given
final var patchResource = newPatchResource();
final var newHolderUuid = UUID.randomUUID();
patchResource.setPartnerRel(new HsOfficeRelationPatchResource());
patchResource.getPartnerRel().setHolderUuid(JsonNullable.of(newHolderUuid));
final var entity = newInitialEntity();
// when
createPatcher(entity).apply(patchResource);
// then
assertThat(entity.getPartnerRel().getHolder().getUuid()).isEqualTo(newHolderUuid);
}
@Test
void patchPartnerContact() {
// given
final var patchResource = newPatchResource();
final var newContactUuid = UUID.randomUUID();
patchResource.setPartnerRel(new HsOfficeRelationPatchResource());
patchResource.getPartnerRel().setContactUuid(JsonNullable.of(newContactUuid));
final var entity = newInitialEntity();
// when
createPatcher(entity).apply(patchResource);
// then
assertThat(entity.getPartnerRel().getContact().getUuid()).isEqualTo(newContactUuid);
}
@Test
void patchPartnerDetails() {
// given
final var patchResource = newPatchResource();
final var newDateOfBirth = LocalDate.now();
patchResource.setDetails(new HsOfficePartnerDetailsPatchResource());
patchResource.getDetails().setDateOfDeath(JsonNullable.of(newDateOfBirth));
final var entity = newInitialEntity();
// when
createPatcher(entity).apply(patchResource);
// then
assertThat(entity.getDetails().getDateOfDeath()).isEqualTo(newDateOfBirth);
}
@Override
protected HsOfficePartnerRbacEntity newInitialEntity() {
final var entity = HsOfficePartnerRbacEntity.builder()
.uuid(INITIAL_PARTNER_UUID)
.partnerNumber(12345)
.partnerRel(HsOfficeRelationRealEntity.builder()
.holder(givenInitialPerson)
.holder(givenInitialPartnerPerson)
.contact(givenInitialContact)
.build())
.details(givenInitialDetails)
@ -66,32 +121,11 @@ class HsOfficePartnerEntityPatcherUnitTest extends PatchUnitTestBase<
return entity;
}
@Override
protected HsOfficePartnerPatchResource newPatchResource() {
return new HsOfficePartnerPatchResource();
}
@Override
protected HsOfficePartnerEntityPatcher createPatcher(final HsOfficePartnerRbacEntity partner) {
return new HsOfficePartnerEntityPatcher(em, partner);
}
@Override
protected Stream<Property> propertyTestDescriptors() {
return Stream.of(
new JsonNullableProperty<>(
"partnerRel",
HsOfficePartnerPatchResource::setPartnerRelUuid,
PATCHED_PARTNER_ROLE_UUID,
HsOfficePartnerRbacEntity::setPartnerRel,
newPartnerRel(PATCHED_PARTNER_ROLE_UUID))
.notNullable()
);
}
private static HsOfficeRelationRealEntity newPartnerRel(final UUID uuid) {
return HsOfficeRelationRealEntity.builder()
.uuid(uuid)
.build();
return new HsOfficePartnerEntityPatcher(mapper, emw, partner);
}
}

View File

@ -1,23 +1,38 @@
package net.hostsharing.hsadminng.hs.office.relation;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactFromResourceConverter;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonTypeResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationPatchResource;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openapitools.jackson.nullable.JsonNullable;
import jakarta.persistence.EntityManager;
import jakarta.validation.ValidationException;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON;
import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.PARTNER;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.assertj.core.api.Assumptions.assumeThat;
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;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@TestInstance(PER_CLASS)
@ExtendWith(MockitoExtension.class)
@ -26,36 +41,84 @@ class HsOfficeRelationPatcherUnitTest extends PatchUnitTestBase<
HsOfficeRelation
> {
static final UUID INITIAL_RELATION_UUID = UUID.randomUUID();
static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID();
private static final UUID INITIAL_RELATION_UUID = UUID.randomUUID();
private static final UUID INITIAL_ANCHOR_UUID = UUID.randomUUID();
private static final UUID INITIAL_HOLDER_UUID = UUID.randomUUID();
private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID();
private static final UUID PATCHED_HOLDER_UUID = UUID.randomUUID();
private static HsOfficePersonInsertResource HOLDER_PATCH_RESOURCE = new HsOfficePersonInsertResource();
{
{
HOLDER_PATCH_RESOURCE.setPersonType(HsOfficePersonTypeResource.NATURAL_PERSON);
HOLDER_PATCH_RESOURCE.setFamilyName("Patched-Holder-Family-Name");
HOLDER_PATCH_RESOURCE.setGivenName("Patched-Holder-Given-Name");
}
};
private static HsOfficePersonRealEntity PATCHED_HOLDER = HsOfficePersonRealEntity.builder()
.uuid(PATCHED_HOLDER_UUID)
.personType(NATURAL_PERSON)
.familyName("Patched-Holder-Family-Name")
.givenName("Patched-Holder-Given-Name")
.build();
private static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID();
private static HsOfficeContactInsertResource CONTACT_PATCH_RESOURCE = new HsOfficeContactInsertResource();
{
{
CONTACT_PATCH_RESOURCE.setCaption("Patched-Contact-Caption");
CONTACT_PATCH_RESOURCE.setEmailAddresses(Map.ofEntries(
Map.entry("main", "patched@example.org")
));
}
};
private static HsOfficeContactRealEntity PATCHED_CONTACT = HsOfficeContactRealEntity.builder()
.uuid(PATCHED_CONTACT_UUID)
.caption("Patched-Contact-Caption")
.emailAddresses(Map.ofEntries(
Map.entry("main", "patched@example.org")
))
.build();
@Mock
EntityManager em;
private EntityManagerWrapper emw;
private StrictMapper mapper;
@BeforeEach
void initMocks() {
lenient().when(em.getReference(eq(HsOfficeContactRealEntity.class), any())).thenAnswer(invocation ->
HsOfficeContactRealEntity.builder().uuid(invocation.getArgument(1)).build());
}
void init() {
mapper = new StrictMapper(emw); // emw is injected after the constructor got called
mapper.addConverter(
new HsOfficeContactFromResourceConverter<>(),
HsOfficeContactInsertResource.class, HsOfficeContactRealEntity.class);
final HsOfficePersonRealEntity givenInitialAnchorPerson = HsOfficePersonRealEntity.builder()
.uuid(UUID.randomUUID())
.build();
final HsOfficePersonRealEntity givenInitialHolderPerson = HsOfficePersonRealEntity.builder()
.uuid(UUID.randomUUID())
.build();
final HsOfficeContactRealEntity givenInitialContact = HsOfficeContactRealEntity.builder()
.uuid(UUID.randomUUID())
.build();
lenient().when(emw.getReference(HsOfficePersonRealEntity.class, PATCHED_HOLDER_UUID)).thenAnswer(
p -> PATCHED_HOLDER);
lenient().when(emw.getReference(HsOfficeContactRealEntity.class, PATCHED_CONTACT_UUID)).thenAnswer(
p -> PATCHED_CONTACT);
}
@Override
protected HsOfficeRelation newInitialEntity() {
final var entity = new HsOfficeRelationRbacEntity();
final var entity = new HsOfficeRelationRealEntity();
entity.setUuid(INITIAL_RELATION_UUID);
entity.setType(HsOfficeRelationType.REPRESENTATIVE);
entity.setAnchor(givenInitialAnchorPerson);
entity.setHolder(givenInitialHolderPerson);
entity.setContact(givenInitialContact);
entity.setType(PARTNER);
entity.setAnchor(HsOfficePersonRealEntity.builder()
.uuid(INITIAL_ANCHOR_UUID)
.personType(LEGAL_PERSON)
.tradeName("Initial-Anchor-Tradename")
.build());
entity.setHolder(HsOfficePersonRealEntity.builder()
.uuid(INITIAL_HOLDER_UUID)
.personType(NATURAL_PERSON)
.familyName("Initial-Holder-Family-Name")
.givenName("Initial-Holder-Given-Name")
.build());
entity.setContact(HsOfficeContactRealEntity.builder()
.uuid(INITIAL_CONTACT_UUID)
.caption("Initial-Contact-Caption")
.build());
return entity;
}
@ -65,24 +128,114 @@ class HsOfficeRelationPatcherUnitTest extends PatchUnitTestBase<
}
@Override
protected HsOfficeRelationEntityPatcher createPatcher(final HsOfficeRelation relation) {
return new HsOfficeRelationEntityPatcher(em, relation);
protected HsOfficeRelationPatcher createPatcher(final HsOfficeRelation relation) {
return new HsOfficeRelationPatcher(mapper, emw, relation);
}
@Override
protected Stream<Property> propertyTestDescriptors() {
return Stream.of(
new JsonNullableProperty<>(
"contact",
"holderUuid",
HsOfficeRelationPatchResource::setHolderUuid,
PATCHED_HOLDER_UUID,
HsOfficeRelation::setHolder,
PATCHED_HOLDER),
new SimpleProperty<>(
"holder",
HsOfficeRelationPatchResource::setHolder,
HOLDER_PATCH_RESOURCE,
HsOfficeRelation::setHolder,
withoutUuid(PATCHED_HOLDER))
.notNullable(),
new JsonNullableProperty<>(
"contactUuid",
HsOfficeRelationPatchResource::setContactUuid,
PATCHED_CONTACT_UUID,
HsOfficeRelation::setContact,
newContact(PATCHED_CONTACT_UUID))
PATCHED_CONTACT),
new SimpleProperty<>(
"contact",
HsOfficeRelationPatchResource::setContact,
CONTACT_PATCH_RESOURCE,
HsOfficeRelation::setContact,
withoutUuid(PATCHED_CONTACT))
.notNullable()
);
}
static HsOfficeContactRealEntity newContact(final UUID uuid) {
return HsOfficeContactRealEntity.builder().uuid(uuid).build();
@Override
protected void willPatchAllProperties() {
// this generic test does not work because either holder or holder.uuid can be set
assumeThat(true).isFalse();
}
@Test
void willThrowExceptionIfHolderAndHolderUuidAreGiven() {
// given
final var givenEntity = newInitialEntity();
final var patchResource = newPatchResource();
patchResource.setHolderUuid(JsonNullable.of(PATCHED_HOLDER_UUID));
patchResource.setHolder(HOLDER_PATCH_RESOURCE);
// when
final var exception = catchThrowable(() -> createPatcher(givenEntity).apply(patchResource));
// then
assertThat(exception).isInstanceOf(ValidationException.class)
.hasMessage("either \"holder\" or \"holder.uuid\" can be given, not both");
}
@Test
void willThrowExceptionIfContactAndContactUuidAreGiven() {
// given
final var givenEntity = newInitialEntity();
final var patchResource = newPatchResource();
patchResource.setContactUuid(JsonNullable.of(PATCHED_CONTACT_UUID));
patchResource.setContact(CONTACT_PATCH_RESOURCE);
// when
final var exception = catchThrowable(() -> createPatcher(givenEntity).apply(patchResource));
// then
assertThat(exception).isInstanceOf(ValidationException.class)
.hasMessage("either \"contact\" or \"contact.uuid\" can be given, not both");
}
@Test
void willPersistNewHolder() {
// given
final var givenEntity = newInitialEntity();
final var patchResource = newPatchResource();
patchResource.setHolder(HOLDER_PATCH_RESOURCE);
// when
createPatcher(givenEntity).apply(patchResource);
// then
verify(emw, times(1)).persist(givenEntity.getHolder());
}
@Test
void willPersistNewContact() {
// given
final var givenEntity = newInitialEntity();
final var patchResource = newPatchResource();
patchResource.setContact(CONTACT_PATCH_RESOURCE);
// when
createPatcher(givenEntity).apply(patchResource);
// then
verify(emw, times(1)).persist(givenEntity.getContact());
}
private HsOfficePersonRealEntity withoutUuid(final HsOfficePersonRealEntity givenWithUuid) {
return givenWithUuid.toBuilder().uuid(null).build();
}
private HsOfficeContactRealEntity withoutUuid(final HsOfficeContactRealEntity givenWithUuid) {
return givenWithUuid.toBuilder().uuid(null).build();
}
}

View File

@ -28,6 +28,7 @@ import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATU
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.UNINCORPORATED_FIRM;
import static net.hostsharing.hsadminng.rbac.grant.RawRbacGrantEntity.distinctGrantDisplaysOf;
import static net.hostsharing.hsadminng.rbac.role.RawRbacRoleEntity.distinctRoleNamesOf;
import static net.hostsharing.hsadminng.rbac.role.RbacRoleType.ADMIN;
import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt;
import static org.assertj.core.api.Assertions.assertThat;
@ -282,7 +283,40 @@ class HsOfficeRelationRepositoryIntegrationTest extends ContextBasedTestWithClea
assertThatRelationIsNotVisibleForUserWithRole(
result.returnedValue(),
"hs_office.contact#fifthcontact:ADMIN");
}
@Test
public void hostsharingAdmin_withoutAssumedRole_canUpdateHolderOfArbitraryRelation() {
// given
context("superuser-alex@hostsharing.net");
final var givenRelation = givenSomeTemporaryRelationBessler(
"Bert", "fifth contact");
final var oldHolderPerson = givenRelation.getHolder();
final var newHolderPerson = personRepo.findPersonByOptionalNameLike("Paul").getFirst();
assertThatRelationActuallyInDatabase(givenRelation);
assertThatRelationIsVisibleForUserWithRole(
givenRelation,
givenRelation.getHolder().roleId(ADMIN));
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
givenRelation.setHolder(newHolderPerson);
return toCleanup(relationRbacRepo.save(givenRelation).load());
});
// then
result.assertSuccessful();
assertThat(result.returnedValue().getHolder().getGivenName()).isEqualTo("Paul");
assertThatRelationIsVisibleForUserWithRole(
result.returnedValue(),
"rbac.global#global:ADMIN");
assertThatRelationIsVisibleForUserWithRole(
result.returnedValue(),
newHolderPerson.roleId(ADMIN));
assertThatRelationIsNotVisibleForUserWithRole(
result.returnedValue(),
oldHolderPerson.roleId(ADMIN));
}
@Test

View File

@ -7,6 +7,7 @@ import net.hostsharing.hsadminng.hs.office.scenarios.contact.RemovePhoneNumberFr
import net.hostsharing.hsadminng.hs.office.scenarios.contact.ReplaceContactData;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateExternalDebitorForPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSelfDebitorForPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSelfDebitorForPartnerWithIdenticalContactData;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSepaMandateForDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.DeleteDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.DontDeleteDefaultDebitor;
@ -265,11 +266,27 @@ class HsOfficeScenarioTests extends ScenarioTest {
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DebitorScenarios {
@Test
@Order(2000)
@Requires("Partner: P-31011 - Michelle Matthieu")
@Produces("Debitor: D-3101100 - Michelle Matthieu")
void shouldCreateSelfDebitorForPartnerWithIdenticalContactData() {
new CreateSelfDebitorForPartnerWithIdenticalContactData(scenarioTest)
.given("partnerNumber", "P-31011")
.given("debitorNumberSuffix", "00") // TODO.impl: could be assigned automatically, but is not yet
.given("billable", true)
.given("vatBusiness", false)
.given("vatReverseCharge", false)
.given("defaultPrefix", "mim")
.doRun()
.keep();
}
@Test
@Order(2010)
@Requires("Partner: P-31010 - Test AG")
@Produces("Debitor: D-3101000 - Test AG - main debitor")
void shouldCreateSelfDebitorForPartner() {
void shouldCreateSelfDebitorForPartnerWithDistinctContactData() {
new CreateSelfDebitorForPartner(scenarioTest)
.given("partnerPersonTradeName", "Test AG")
.given("billingContactCaption", "Test AG - billing department")
@ -654,7 +671,7 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Test
@Order(6010)
@Requires("Partner: P-31011 - Michelle Matthieu")
@Requires("Debitor: D-3101100 - Michelle Matthieu") // which should also get updated
void shouldReplaceDeceasedPartnerByCommunityOfHeirs() {
new ReplaceDeceasedPartnerWithCommunityOfHeirs(scenarioTest)
.given("partnerNumber", "P-31011")

View File

@ -0,0 +1,45 @@
package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED;
public class CreateSelfDebitorForPartnerWithIdenticalContactData
extends UseCase<CreateSelfDebitorForPartnerWithIdenticalContactData> {
public CreateSelfDebitorForPartnerWithIdenticalContactData(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
withTitle("Determine Partner-Person UUID", () ->
httpGet("/api/hs/office/partners/" + uriEncoded("%{partnerNumber}"))
.reportWithResponse().expecting(HttpStatus.OK).expecting(JSON)
.extractUuidAlias("partnerRel.holder.uuid", "partnerPersonUuid")
.extractUuidAlias("partnerRel.contact.uuid", "partnerContactUuid")
);
return httpPost("/api/hs/office/debitors", usingJsonBody("""
{
"debitorRel": {
"anchor.uuid": ${partnerPersonUuid},
"holder.uuid": ${partnerPersonUuid},
"contact.uuid": ${partnerContactUuid}
},
"debitorNumberSuffix": ${debitorNumberSuffix},
"billable": ${billable},
"vatId": ${vatId???},
"vatCountryCode": ${vatCountryCode???},
"vatBusiness": ${vatBusiness},
"vatReverseCharge": ${vatReverseCharge},
"defaultPrefix": ${defaultPrefix}
}
"""))
.expecting(CREATED).expecting(JSON);
}
}

View File

@ -12,101 +12,81 @@ public class ReplaceDeceasedPartnerWithCommunityOfHeirs extends UseCase<ReplaceD
public ReplaceDeceasedPartnerWithCommunityOfHeirs(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Person: Hostsharing eG", () ->
httpGet("/api/hs/office/persons?name=Hostsharing+eG")
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"Even in production data we expect this query to return just a single result." // TODO.impl: add constraint?
);
obtain("Partner: %{partnerNumber}", () ->
httpGet("/api/hs/office/partners/%{partnerNumber}")
.reportWithResponse().expecting(OK).expecting(JSON),
obtain("Partner: %{partnerNumber}",
() -> httpGet("/api/hs/office/partners/%{partnerNumber}")
.reportWithResponse().expecting(OK).expecting(JSON),
response -> response.getFromBody("uuid"),
"Even in production data we expect this query to return just a single result." // TODO.impl: add constraint?
"Even in production data we expect this query to return just a single result."
// TODO.impl: add constraint?
)
.extractValue("partnerRel.holder.familyName", "familyNameOfDeceasedPerson")
.extractValue("partnerRel.holder.givenName", "givenNameOfDeceasedPerson")
.extractUuidAlias("partnerRel.holder.uuid", "Person: %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}");
.extractUuidAlias(
"partnerRel.holder.uuid",
"Person: %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}");
obtain("Partner-Relation: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", () ->
httpPost("/api/hs/office/relations", usingJsonBody("""
{
"type": "PARTNER",
"anchor.uuid": ${Person: Hostsharing eG},
"holder": {
"personType": "UNINCORPORATED_FIRM",
"tradeName": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}",
},
"contact": {
"caption": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}",
"postalAddress": {
"name": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}",
"co": "%{representativeGivenName} %{representativeFamilyName}",
%{communityOfHeirsPostalAddress}
},
"phoneNumbers": {
"office": ${communityOfHeirsOfficePhoneNumber}
},
"emailAddresses": {
"main": ${communityOfHeirsEmailAddress}
}
}
}
"""))
.reportWithResponse().expecting(CREATED).expecting(JSON)
)
.extractUuidAlias("contact.uuid", "Contact: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}")
.extractUuidAlias("holder.uuid", "Person: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}");
obtain("Representative-Relation: %{representativeGivenName} %{representativeFamilyName} for Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", () ->
httpPost("/api/hs/office/relations", usingJsonBody("""
{
"type": "REPRESENTATIVE",
"anchor.uuid": ${Person: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}},
"holder": {
"personType": "NATURAL_PERSON",
"givenName": ${representativeGivenName},
"familyName": ${representativeFamilyName}
},
"contact.uuid": ${Contact: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}}
}
"""))
.reportWithResponse().expecting(CREATED).expecting(JSON)
).extractUuidAlias("holder.uuid", "Person: %{representativeGivenName} %{representativeFamilyName}");
obtain("Partner: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}", () ->
httpPatch("/api/hs/office/partners/%{Partner: %{partnerNumber}}", usingJsonBody("""
{
"partnerRel.uuid": ${Partner-Relation: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}}
}
"""))
.expecting(HttpStatus.OK)
withTitle("New Partner-Person+Contact: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}",
() -> httpPatch("/api/hs/office/partners/%{Partner: %{partnerNumber}}",
usingJsonBody("""
{
"wrong1": false,
"partnerRel": {
"wrong2": false,
"holder": {
"personType": "UNINCORPORATED_FIRM",
"tradeName": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}",
},
"contact": {
"wrong3": false,
"caption": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}",
"postalAddress": {
"wrong4": false,
"name": "Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}",
"co": "%{representativeGivenName} %{representativeFamilyName}",
%{communityOfHeirsPostalAddress}
},
"phoneNumbers": {
"office": ${communityOfHeirsOfficePhoneNumber}
},
"emailAddresses": {
"main": ${communityOfHeirsEmailAddress}
}
}
}
}
"""))
.reportWithResponse().expecting(HttpStatus.OK).expecting(JSON)
.extractUuidAlias(
"partnerRel.holder.uuid",
"Person: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}")
.extractUuidAlias(
"partnerRel.contact.uuid",
"Contact: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}")
);
// TODO.test: missing steps Debitor, Membership, Coop-Shares+Assets
// Debitors
// die Erbengemeinschaft wird als Anchor-Person (Partner) in die Debitor-Relations eingetragen
// der neue Rechnungsempfänger (z.B. auch ggf. Rechtsanwalt) wird als Holder-Person (Debitor-Person) in die Debitor-Relations eingetragen -- oder neu?
// Membership
// intro: die Mitgliedschaft geht juristisch gesehen auf die Erbengemeinschaft über
// die bisherige Mitgliedschaft als DECEASED mit Ende-Datum=Todesdatum markieren
// eine neue Mitgliedschaft (-00) mit dem Start-Datum=Todesdatum+1 anlegen
// die Geschäftsanteile per share-tx: TRANSFER→ADOPT an die Erbengemeinschaft übertragen
// die Geschäftsguthaben per asset-tx: TRANSFER→ADOPT an die Erbengemeinschaft übertragen
obtain(
"Representative-Relation: %{representativeGivenName} %{representativeFamilyName} for Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}",
() -> httpPost("/api/hs/office/relations",
usingJsonBody("""
{
"type": "REPRESENTATIVE",
"anchor.uuid": ${Person: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}},
"holder": {
"personType": "NATURAL_PERSON",
"givenName": ${representativeGivenName},
"familyName": ${representativeFamilyName}
},
"contact.uuid": ${Contact: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}}
}
"""))
.reportWithResponse().expecting(CREATED).expecting(JSON)
)
.extractUuidAlias("holder.uuid", "Person: %{representativeGivenName} %{representativeFamilyName}");
// outro: die Erbengemeinschaft hat eine Frist von 6 Monaten, um die Mitgliedschaft einer Person zu übertragen
// →nächster "Drecksfall"
@ -120,20 +100,44 @@ public class ReplaceDeceasedPartnerWithCommunityOfHeirs extends UseCase<ReplaceD
"Verify the Updated Partner",
() -> httpGet("/api/hs/office/partners/%{partnerNumber}")
.expecting(OK).expecting(JSON).expectObject(),
path("partnerRel.holder.tradeName").contains("Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}")
path("partnerRel.holder.tradeName").contains(
"Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}"),
path("partnerRel.contact.postalAddress").lenientlyContainsJson("§{communityOfHeirsPostalAddress}"),
path("partnerRel.contact.phoneNumbers.office").contains("%{communityOfHeirsOfficePhoneNumber}"),
path("partnerRel.contact.emailAddresses.main").contains("%{communityOfHeirsEmailAddress}")
);
// TODO.test: Verify the EX_PARTNER-Relation, once we fixed the anchor problem, see HsOfficePartnerController
// (net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerController.optionallyCreateExPartnerRelation)
verify(
"Verify the Ex-Partner-Relation",
() -> httpGet(
"/api/hs/office/relations?relationType=EX_PARTNER&personUuid=%{Person: %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}}")
.expecting(OK).expecting(JSON).expectArrayElements(1),
path("[0].anchor.tradeName").contains(
"Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}")
);
verify(
"Verify the Representative-Relation",
() -> httpGet("/api/hs/office/relations?relationType=REPRESENTATIVE&personUuid=%{Person: %{representativeGivenName} %{representativeFamilyName}}")
() -> httpGet(
"/api/hs/office/relations?relationType=REPRESENTATIVE&personUuid=%{Person: Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}}")
.expecting(OK).expecting(JSON).expectArrayElements(1),
path("[0].anchor.tradeName").contains("Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}"),
path("[0].holder.familyName").contains("%{representativeFamilyName}")
path("[0].anchor.tradeName").contains(
"Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}"),
path("[0].holder.familyName").contains("%{representativeFamilyName}"),
path("[0].contact.postalAddress").lenientlyContainsJson("§{communityOfHeirsPostalAddress}"),
path("[0].contact.phoneNumbers.office").contains("%{communityOfHeirsOfficePhoneNumber}"),
path("[0].contact.emailAddresses.main").contains("%{communityOfHeirsEmailAddress}")
);
// TODO.test: Verify Debitor, Membership, Coop-Shares and Coop-Assets once implemented
verify(
"Verify the Debitor-Relation",
() -> httpGet(
"/api/hs/office/debitors?partnerNumber=%{partnerNumber}")
.expecting(OK).expecting(JSON).expectArrayElements(1),
path("[0].debitorRel.anchor.tradeName").contains(
"Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}"),
path("[0].debitorRel.holder.tradeName").contains(
"Erbengemeinschaft %{givenNameOfDeceasedPerson} %{familyNameOfDeceasedPerson}")
);
}
}

View File

@ -5,6 +5,7 @@ import net.hostsharing.hsadminng.hs.scenarios.UseCase.HttpResponse;
import java.util.function.Consumer;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
import static org.junit.jupiter.api.Assertions.fail;
public class PathAssertion {
@ -27,6 +28,18 @@ public class PathAssertion {
};
}
@SuppressWarnings({ "unchecked", "rawtypes" })
public Consumer<UseCase.HttpResponse> lenientlyContainsJson(final String resolvableValue) {
return response -> {
try {
lenientlyEquals(ScenarioTest.resolve(resolvableValue, DROP_COMMENTS)).matches(response.getFromBody(path)) ;
} catch (final AssertionError e) {
// without this, the error message is often lacking important context
fail(e.getMessage() + " in `path(\"" + path + "\").contains(\"" + resolvableValue + "\")`" );
}
};
}
public Consumer<HttpResponse> doesNotExist() {
return response -> {
try {

View File

@ -32,6 +32,12 @@ public class TemplateResolver {
return jsonQuoted(value);
}
},
JSON_OBJECT('§'){
@Override
String convert(final Object value, final Resolver resolver) {
return jsonObject(value);
}
},
URI_ENCODED('&'){
@Override
String convert(final Object value, final Resolver resolver) {
@ -213,4 +219,12 @@ public class TemplateResolver {
default -> "\"" + value + "\"";
};
}
private static String jsonObject(final Object value) {
return switch (value) {
case null -> null;
case String string -> "{" + string.replace("\n", " ") + "}";
default -> throw new IllegalArgumentException("can not format " + value.getClass() + " (" + value + ") as JSON object");
};
}
}

View File

@ -19,7 +19,7 @@ import static org.assertj.core.api.Assumptions.assumeThat;
public abstract class PatchUnitTestBase<R, E> {
@Test
void willPatchNoProperty() {
protected void willPatchNoProperty() {
// given
final var givenEntity = newInitialEntity();
final var patchResource = newPatchResource();
@ -73,7 +73,7 @@ public abstract class PatchUnitTestBase<R, E> {
@ParameterizedTest
@MethodSource("propertyTestCases")
void willThrowExceptionIfNotNullableValueIsNull(final Property<R, Object, E, Object> testCase) {
protected void willThrowExceptionIfNotNullableValueIsNull(final Property<R, Object, E, Object> testCase) {
assumeThat(testCase instanceof JsonNullableProperty).isTrue();
assumeThat(testCase.nullable).isFalse();
@ -94,7 +94,7 @@ public abstract class PatchUnitTestBase<R, E> {
@ParameterizedTest
@MethodSource("propertyTestCases")
void willPatchOnlyGivenPropertyToNull(final Property<R, Object, E, Object> testCase) {
protected void willPatchOnlyGivenPropertyToNull(final Property<R, Object, E, Object> testCase) {
assumeThat(testCase.nullable).isTrue();
// given
@ -113,7 +113,7 @@ public abstract class PatchUnitTestBase<R, E> {
@ParameterizedTest
@MethodSource("propertyTestCases")
void willNotPatchIfGivenPropertyNotGiven(final Property<R, Object, E, Object> testCase) {
protected void willNotPatchIfGivenPropertyNotGiven(final Property<R, Object, E, Object> testCase) {
// given
final var givenEntity = newInitialEntity();