create relation with holder- and contact-data, and search for contact emailAddress + relation mark #136

Merged
hsh-michaelhoennig merged 12 commits from feature/create-relation-with-holder-and-contact-data into master 2024-12-13 14:09:03 +01:00
18 changed files with 354 additions and 119 deletions

View File

@ -13,8 +13,16 @@ public class Validate {
return new Validate(variableNames); return new Validate(variableNames);
} }
public final void atMaxOneNonNull(final Object var1, final Object var2) { public final void atMaxOne(final Object var1, final Object var2) {
if (var1 != null && var2 != null) { if (var1 != null && var2 != null) {
throw new ValidationException(
"At maximum one of (" + variableNames + ") must be non-null, " +
"but are (" + var1 + ", " + var2 + ")");
}
}
public final void exactlyOne(final Object var1, final Object var2) {
if ((var1 != null) == (var2 != null)) {
throw new ValidationException( throw new ValidationException(
"Exactly one of (" + variableNames + ") must be non-null, " + "Exactly one of (" + variableNames + ") must be non-null, " +
"but are (" + var1 + ", " + var2 + ")"); "but are (" + var1 + ", " + var2 + ")");

View File

@ -17,6 +17,7 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.errors.Validate.validate;
import static net.hostsharing.hsadminng.mapper.KeyValueMap.from; import static net.hostsharing.hsadminng.mapper.KeyValueMap.from;
@RestController @RestController
@ -38,10 +39,14 @@ public class HsOfficeContactController implements HsOfficeContactsApi {
public ResponseEntity<List<HsOfficeContactResource>> getListOfContacts( public ResponseEntity<List<HsOfficeContactResource>> getListOfContacts(
final String currentSubject, final String currentSubject,
final String assumedRoles, final String assumedRoles,
final String caption) { final String caption,
final String emailAddress) {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var entities = contactRepo.findContactByOptionalCaptionLike(caption); validate("caption, emailAddress").atMaxOne(caption, emailAddress);
final var entities = emailAddress != null
? contactRepo.findContactByEmailAddress(emailAddress)
: contactRepo.findContactByOptionalCaptionLike(caption);
final var resources = mapper.mapList(entities, HsOfficeContactResource.class); final var resources = mapper.mapList(entities, HsOfficeContactResource.class);
return ResponseEntity.ok(resources); return ResponseEntity.ok(resources);

View File

@ -21,6 +21,16 @@ public interface HsOfficeContactRbacRepository extends Repository<HsOfficeContac
@Timed("app.office.contacts.repo.findContactByOptionalCaptionLike.rbac") @Timed("app.office.contacts.repo.findContactByOptionalCaptionLike.rbac")
List<HsOfficeContactRbacEntity> findContactByOptionalCaptionLike(String caption); List<HsOfficeContactRbacEntity> findContactByOptionalCaptionLike(String caption);
@Query(value = """
select c.* from hs_office.contact_rv c
where exists (
SELECT 1 FROM jsonb_each_text(c.emailAddresses) AS kv(key, value)
WHERE kv.value LIKE :emailAddressRegEx
)
""", nativeQuery = true)
@Timed("app.office.contacts.repo.findContactByEmailAddress.rbac")
List<HsOfficeContactRbacEntity> findContactByEmailAddress(final String emailAddressRegEx);
@Timed("app.office.contacts.repo.save.rbac") @Timed("app.office.contacts.repo.save.rbac")
HsOfficeContactRbacEntity save(final HsOfficeContactRbacEntity entity); HsOfficeContactRbacEntity save(final HsOfficeContactRbacEntity entity);

View File

@ -43,7 +43,7 @@ public class HsOfficeMembershipController implements HsOfficeMembershipsApi {
final String partnerNumber) { final String partnerNumber) {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
validate("partnerUuid, partnerNumber").atMaxOneNonNull(partnerUuid, partnerNumber); validate("partnerUuid, partnerNumber").atMaxOne(partnerUuid, partnerNumber);
final var entities = partnerNumber != null final var entities = partnerNumber != null
? membershipRepo.findMembershipsByPartnerNumber( ? membershipRepo.findMembershipsByPartnerNumber(

View File

@ -35,10 +35,10 @@ public class HsOfficePersonController implements HsOfficePersonsApi {
public ResponseEntity<List<HsOfficePersonResource>> getListOfPersons( public ResponseEntity<List<HsOfficePersonResource>> getListOfPersons(
final String currentSubject, final String currentSubject,
final String assumedRoles, final String assumedRoles,
final String caption) { final String name) {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var entities = personRepo.findPersonByOptionalNameLike(caption); final var entities = personRepo.findPersonByOptionalNameLike(name);
final var resources = mapper.mapList(entities, HsOfficePersonResource.class); final var resources = mapper.mapList(entities, HsOfficePersonResource.class);
return ResponseEntity.ok(resources); return ResponseEntity.ok(resources);

View File

@ -2,9 +2,12 @@ package net.hostsharing.hsadminng.hs.office.relation;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.errors.Validate;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationsApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.mapper.StandardMapper; import net.hostsharing.hsadminng.mapper.StandardMapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -20,6 +23,7 @@ import java.util.NoSuchElementException;
import java.util.UUID; import java.util.UUID;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.mapper.KeyValueMap.from;
@RestController @RestController
public class HsOfficeRelationController implements HsOfficeRelationsApi { public class HsOfficeRelationController implements HsOfficeRelationsApi {
@ -31,10 +35,10 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
private StandardMapper mapper; private StandardMapper mapper;
@Autowired @Autowired
private HsOfficeRelationRbacRepository relationRbacRepo; private HsOfficeRelationRbacRepository rbacRelationRepo;
@Autowired @Autowired
private HsOfficePersonRealRepository personRepo; private HsOfficePersonRealRepository realPersonRepo;
@Autowired @Autowired
private HsOfficeContactRealRepository realContactRepo; private HsOfficeContactRealRepository realContactRepo;
@ -50,15 +54,16 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
final String assumedRoles, final String assumedRoles,
final UUID personUuid, final UUID personUuid,
final HsOfficeRelationTypeResource relationType, final HsOfficeRelationTypeResource relationType,
final String mark,
final String personData, final String personData,
final String contactData) { final String contactData) {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final List<HsOfficeRelationRbacEntity> entities = final List<HsOfficeRelationRbacEntity> entities =
relationRbacRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData( rbacRelationRepo.findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData(
personUuid, personUuid,
relationType == null ? null : HsOfficeRelationType.valueOf(relationType.name()), relationType == null ? null : HsOfficeRelationType.valueOf(relationType.name()),
personData, contactData); mark, personData, contactData);
final var resources = mapper.mapList(entities, HsOfficeRelationResource.class, final var resources = mapper.mapList(entities, HsOfficeRelationResource.class,
RELATION_ENTITY_TO_RESOURCE_POSTMAPPER); RELATION_ENTITY_TO_RESOURCE_POSTMAPPER);
@ -78,17 +83,34 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
final var entityToSave = new HsOfficeRelationRbacEntity(); final var entityToSave = new HsOfficeRelationRbacEntity();
entityToSave.setType(HsOfficeRelationType.valueOf(body.getType())); entityToSave.setType(HsOfficeRelationType.valueOf(body.getType()));
entityToSave.setMark(body.getMark()); entityToSave.setMark(body.getMark());
entityToSave.setAnchor(personRepo.findByUuid(body.getAnchorUuid()).orElseThrow(
entityToSave.setAnchor(realPersonRepo.findByUuid(body.getAnchorUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find Person by anchorUuid: " + body.getAnchorUuid()) () -> new NoSuchElementException("cannot find Person by anchorUuid: " + body.getAnchorUuid())
)); ));
entityToSave.setHolder(personRepo.findByUuid(body.getHolderUuid()).orElseThrow(
Validate.validate("anchor, anchor.uuid").exactlyOne(body.getHolder(), body.getHolderUuid());
if ( body.getHolderUuid() != null) {
entityToSave.setHolder(realPersonRepo.findByUuid(body.getHolderUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find Person by holderUuid: " + body.getHolderUuid()) () -> new NoSuchElementException("cannot find Person by holderUuid: " + body.getHolderUuid())
)); ));
} else {
entityToSave.setHolder(realPersonRepo.save(
mapper.map(body.getHolder(), HsOfficePersonRealEntity.class)
) );
}
Validate.validate("contact, contact.uuid").exactlyOne(body.getContact(), body.getContactUuid());
if ( body.getContactUuid() != null) {
entityToSave.setContact(realContactRepo.findByUuid(body.getContactUuid()).orElseThrow( entityToSave.setContact(realContactRepo.findByUuid(body.getContactUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find Contact by contactUuid: " + body.getContactUuid()) () -> new NoSuchElementException("cannot find Contact by contactUuid: " + body.getContactUuid())
)); ));
} else {
entityToSave.setContact(realContactRepo.save(
mapper.map(body.getContact(), HsOfficeContactRealEntity.class, CONTACT_RESOURCE_TO_ENTITY_POSTMAPPER)
) );
}
final var saved = relationRbacRepo.save(entityToSave); final var saved = rbacRelationRepo.save(entityToSave);
final var uri = final var uri =
MvcUriComponentsBuilder.fromController(getClass()) MvcUriComponentsBuilder.fromController(getClass())
@ -110,7 +132,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var result = relationRbacRepo.findByUuid(relationUuid); final var result = rbacRelationRepo.findByUuid(relationUuid);
if (result.isEmpty()) { if (result.isEmpty()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
@ -126,7 +148,7 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
final UUID relationUuid) { final UUID relationUuid) {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var result = relationRbacRepo.deleteByUuid(relationUuid); final var result = rbacRelationRepo.deleteByUuid(relationUuid);
if (result == 0) { if (result == 0) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
@ -145,11 +167,11 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
context.define(currentSubject, assumedRoles); context.define(currentSubject, assumedRoles);
final var current = relationRbacRepo.findByUuid(relationUuid).orElseThrow(); final var current = rbacRelationRepo.findByUuid(relationUuid).orElseThrow();
new HsOfficeRelationEntityPatcher(em, current).apply(body); new HsOfficeRelationEntityPatcher(em, current).apply(body);
final var saved = relationRbacRepo.save(current); final var saved = rbacRelationRepo.save(current);
final var mapped = mapper.map(saved, HsOfficeRelationResource.class); final var mapped = mapper.map(saved, HsOfficeRelationResource.class);
return ResponseEntity.ok(mapped); return ResponseEntity.ok(mapped);
} }
@ -159,4 +181,11 @@ public class HsOfficeRelationController implements HsOfficeRelationsApi {
resource.setHolder(mapper.map(entity.getHolder(), HsOfficePersonResource.class)); resource.setHolder(mapper.map(entity.getHolder(), HsOfficePersonResource.class));
resource.setContact(mapper.map(entity.getContact(), HsOfficeContactResource.class)); resource.setContact(mapper.map(entity.getContact(), HsOfficeContactResource.class));
}; };
@SuppressWarnings("unchecked")
final BiConsumer<HsOfficeContactInsertResource, HsOfficeContactRealEntity> CONTACT_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.putEmailAddresses(from(resource.getEmailAddresses()));
entity.putPhoneNumbers(from(resource.getPhoneNumbers()));
};
} }

View File

@ -26,24 +26,29 @@ public interface HsOfficeRelationRbacRepository extends Repository<HsOfficeRelat
* * * *
* @param personUuid the optional UUID of the anchorPerson or holderPerson * @param personUuid the optional UUID of the anchorPerson or holderPerson
* @param relationType the type of the relation * @param relationType the type of the relation
* @param mark the mark (use '%' for wildcard), case ignored
* @param personData a string to match the persons tradeName, familyName or givenName (use '%' for wildcard), case ignored * @param personData a string to match the persons tradeName, familyName or givenName (use '%' for wildcard), case ignored
* @param contactData a string to match the contacts caption, postalAddress, emailAddresses or phoneNumbers (use '%' for wildcard), case ignored * @param contactData a string to match the contacts caption, postalAddress, emailAddresses or phoneNumbers (use '%' for wildcard), case ignored
* @return a list of (accessible) relations which match all given criteria * @return a list of (accessible) relations which match all given criteria
*/ */
default List<HsOfficeRelationRbacEntity> findRelationRelatedToPersonUuidRelationTypePersonAndContactData( default List<HsOfficeRelationRbacEntity> findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData(
final UUID personUuid, final UUID personUuid,
final HsOfficeRelationType relationType, final HsOfficeRelationType relationType,
final String mark,
final String personData, final String personData,
final String contactData) { final String contactData) {
return findRelationRelatedToPersonUuidRelationTypePersonAndContactDataImpl( return findRelationRelatedToPersonUuidRelationByTypeMarkPersonAndContactDataImpl(
personUuid, toStringOrNull(relationType), toSqlLikeOperand(personData), toSqlLikeOperand(contactData)); personUuid, toStringOrNull(relationType),
toSqlLikeOperand(mark), toSqlLikeOperand(personData), toSqlLikeOperand(contactData));
} }
// TODO: use ELIKE instead of lower(...) LIKE ...? Or use jsonb_path with RegEx like emailAddressRegEx in ContactRepo?
@Query(value = """ @Query(value = """
SELECT rel FROM HsOfficeRelationRbacEntity AS rel SELECT rel FROM HsOfficeRelationRbacEntity AS rel
WHERE (:relationType IS NULL OR CAST(rel.type AS String) = :relationType) WHERE (:relationType IS NULL OR CAST(rel.type AS String) = :relationType)
AND ( :personUuid IS NULL AND ( :personUuid IS NULL
OR rel.anchor.uuid = :personUuid OR rel.holder.uuid = :personUuid ) OR rel.anchor.uuid = :personUuid OR rel.holder.uuid = :personUuid )
AND ( :mark IS NULL OR lower(rel.mark) LIKE :mark )
AND ( :personData IS NULL AND ( :personData IS NULL
OR lower(rel.anchor.tradeName) LIKE :personData OR lower(rel.holder.tradeName) LIKE :personData OR lower(rel.anchor.tradeName) LIKE :personData OR lower(rel.holder.tradeName) LIKE :personData
OR lower(rel.anchor.familyName) LIKE :personData OR lower(rel.holder.familyName) LIKE :personData OR lower(rel.anchor.familyName) LIKE :personData OR lower(rel.holder.familyName) LIKE :personData
@ -54,10 +59,11 @@ public interface HsOfficeRelationRbacRepository extends Repository<HsOfficeRelat
OR lower(CAST(rel.contact.emailAddresses AS String)) LIKE :contactData OR lower(CAST(rel.contact.emailAddresses AS String)) LIKE :contactData
OR lower(CAST(rel.contact.phoneNumbers AS String)) LIKE :contactData ) OR lower(CAST(rel.contact.phoneNumbers AS String)) LIKE :contactData )
""") """)
@Timed("app.office.relations.repo.findRelationRelatedToPersonUuidRelationTypePersonAndContactDataImpl.rbac") @Timed("app.office.relations.repo.findRelationRelatedToPersonUuidRelationByTypeMarkPersonAndContactDataImpl.rbac")
List<HsOfficeRelationRbacEntity> findRelationRelatedToPersonUuidRelationTypePersonAndContactDataImpl( List<HsOfficeRelationRbacEntity> findRelationRelatedToPersonUuidRelationByTypeMarkPersonAndContactDataImpl(
final UUID personUuid, final UUID personUuid,
final String relationType, final String relationType,
final String mark,
final String personData, final String personData,
final String contactData); final String contactData);

View File

@ -7,12 +7,19 @@ get:
parameters: parameters:
- $ref: 'auth.yaml#/components/parameters/currentSubject' - $ref: 'auth.yaml#/components/parameters/currentSubject'
- $ref: 'auth.yaml#/components/parameters/assumedRoles' - $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: name - name: caption
in: query in: query
required: false required: false
schema: schema:
type: string type: string
description: Prefix of caption to filter the results. description: Beginning of caption to filter the results.
- name: emailAddress
in: query
required: false
schema:
type: string
description:
Email-address to filter the results, use '%' as wildcard.
responses: responses:
"200": "200":
description: OK description: OK

View File

@ -52,6 +52,8 @@ components:
holder.uuid: holder.uuid:
type: string type: string
format: uuid format: uuid
holder:
$ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert'
type: type:
type: string type: string
nullable: true nullable: true
@ -61,11 +63,17 @@ components:
contact.uuid: contact.uuid:
type: string type: string
format: uuid format: uuid
contact:
$ref: 'hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContactInsert'
required: required:
- anchor.uuid - anchor.uuid
- holder.uuid
- type - type
- contact.uuid # soon we might need to be able to use this:
# https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410
# For now we just describe the conditionally required properties:
description:
Additionally to `type` and `anchor.uuid`, either `anchor.uuid` or `anchor`
and either `contact` or `contact.uuid` need to be given.
# relation created as a sub-element with implicitly known type # relation created as a sub-element with implicitly known type
HsOfficeRelationSubInsert: HsOfficeRelationSubInsert:

View File

@ -21,7 +21,13 @@ get:
required: false required: false
schema: schema:
$ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationType' $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationType'
description: Prefix of name properties from holder or contact to filter the results. description: Beginning of name properties from holder or contact to filter the results.
- name: mark
in: query
required: false
schema:
type: string
description:
- name: personData - name: personData
in: query in: query
required: false required: false

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.errors; package net.hostsharing.hsadminng.errors;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import jakarta.validation.ValidationException; import jakarta.validation.ValidationException;
@ -9,23 +10,53 @@ import static org.assertj.core.api.Assertions.catchThrowable;
class ValidateUnitTest { class ValidateUnitTest {
@Nested
class AtMaxOne {
@Test @Test
void shouldFailValidationIfBothParametersAreNotNull() { void shouldFailValidationIfBothParametersAreNotNull() {
final var throwable = catchThrowable(() -> final var throwable = catchThrowable(() ->
Validate.validate("var1, var2").atMaxOneNonNull("val1", "val2") Validate.validate("var1, var2").atMaxOne("val1", "val2")
);
assertThat(throwable).isInstanceOf(ValidationException.class)
.hasMessage("At maximum one of (var1, var2) must be non-null, but are (val1, val2)");
}
@Test
void shouldNotFailValidationIfBothParametersAreNull() {
Validate.validate("var1, var2").atMaxOne(null, null);
}
@Test
void shouldNotFailValidationIfExactlyOneParameterIsNonNull() {
Validate.validate("var1, var2").atMaxOne("val1", null);
Validate.validate("var1, var2").atMaxOne(null, "val2");
}
}
@Nested
class ExactlyOne {
@Test
void shouldFailValidationIfBothParametersAreNotNull() {
final var throwable = catchThrowable(() ->
Validate.validate("var1, var2").exactlyOne("val1", "val2")
); );
assertThat(throwable).isInstanceOf(ValidationException.class) assertThat(throwable).isInstanceOf(ValidationException.class)
.hasMessage("Exactly one of (var1, var2) must be non-null, but are (val1, val2)"); .hasMessage("Exactly one of (var1, var2) must be non-null, but are (val1, val2)");
} }
@Test @Test
void shouldNotFailValidationIfBothParametersAreull() { void shouldFailValidationIfBothParametersAreNull() {
Validate.validate("var1, var2").atMaxOneNonNull(null, null); final var throwable = catchThrowable(() ->
Validate.validate("var1, var2").exactlyOne(null, null)
);
assertThat(throwable).isInstanceOf(ValidationException.class)
.hasMessage("Exactly one of (var1, var2) must be non-null, but are (null, null)");
} }
@Test @Test
void shouldNotFailValidationIfExactlyOneParameterIsNonNull() { void shouldNotFailValidationIfExactlyOneParameterIsNonNull() {
Validate.validate("var1, var2").atMaxOneNonNull("val1", null); Validate.validate("var1, var2").exactlyOne("val1", null);
Validate.validate("var1, var2").atMaxOneNonNull(null, "val2"); Validate.validate("var1, var2").exactlyOne(null, "val2");
}
} }
} }

View File

@ -127,7 +127,7 @@ class HsOfficeContactRbacRepositoryIntegrationTest extends ContextBasedTestWithC
} }
@Nested @Nested
class FindAllContacts { class FindContacts {
@Test @Test
public void globalAdmin_withoutAssumedRole_canViewAllContacts() { public void globalAdmin_withoutAssumedRole_canViewAllContacts() {
@ -184,6 +184,22 @@ class HsOfficeContactRbacRepositoryIntegrationTest extends ContextBasedTestWithC
} }
} }
@Nested
class FindByEmailAddress {
@Test
public void globalAdmin_withoutAssumedRole_canFindContactsByEmailAddress() {
// given
context("superuser-alex@hostsharing.net", null);
// when
final var result = contactRepo.findContactByEmailAddress("%@secondcontact.example.com");
// then
exactlyTheseContactsAreReturned(result, "second contact");
}
}
@Nested @Nested
class DeleteByUuid { class DeleteByUuid {

View File

@ -223,7 +223,7 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
class AddRelation { class AddRelation {
@Test @Test
void globalAdmin_withoutAssumedRole_canAddRelation() { void globalAdmin_withoutAssumedRole_canAddRelationWithHolderUuidAndContactUuid() {
context.define("superuser-alex@hostsharing.net"); context.define("superuser-alex@hostsharing.net");
final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0); final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0);
@ -269,6 +269,61 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
assertThat(newSubjectUuid).isNotNull(); assertThat(newSubjectUuid).isNotNull();
} }
@Test
void globalAdmin_withoutAssumedRole_canAddRelationWithHolderAndContactData() {
context.define("superuser-alex@hostsharing.net");
final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Third").get(0);
final var location = RestAssured // @formatter:off
.given()
.header("current-subject", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"type": "%s",
"mark": "%s",
"anchor.uuid": "%s",
"holder": {
"personType": "NATURAL_PERSON",
"familyName": "Person",
"givenName": "Temp"
},
"contact": {
"caption": "Temp Contact",
"emailAddresses": {
"main": "test@example.org"
}
}
}
""".formatted(
HsOfficeRelationTypeResource.SUBSCRIBER,
"operations-discuss",
givenAnchorPerson.getUuid()
)
)
.port(port)
.when()
.post("http://localhost/api/hs/office/relations")
.then().log().all().assertThat()
.statusCode(201)
.contentType(ContentType.JSON)
.body("uuid", isUuidValid())
.body("type", is("SUBSCRIBER"))
.body("mark", is("operations-discuss"))
.body("anchor.tradeName", is("Third OHG"))
.body("holder.givenName", is("Temp"))
.body("holder.familyName", is("Person"))
.body("contact.caption", is("Temp Contact"))
.header("Location", startsWith("http://localhost"))
.extract().header("Location"); // @formatter:on
// finally, the new relation can be accessed under the generated UUID
final var newSubjectUuid = toCleanup(HsOfficeRelationRealEntity.class, UUID.fromString(
location.substring(location.lastIndexOf('/') + 1)));
assertThat(newSubjectUuid).isNotNull();
}
@Test @Test
void globalAdmin_canNotAddRelation_ifAnchorPersonDoesNotExist() { void globalAdmin_canNotAddRelation_ifAnchorPersonDoesNotExist() {
@ -277,7 +332,7 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0); final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0);
final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0); final var givenContact = contactrealRepo.findContactByOptionalCaptionLike("fourth").get(0);
final var location = RestAssured // @formatter:off RestAssured // @formatter:off
.given() .given()
.header("current-subject", "superuser-alex@hostsharing.net") .header("current-subject", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON) .contentType(ContentType.JSON)

View File

@ -193,7 +193,7 @@ class HsOfficeRelationRepositoryIntegrationTest extends ContextBasedTestWithClea
.findFirst().orElseThrow(); .findFirst().orElseThrow();
// when: // when:
final var result = relationRbacRepo.findRelationRelatedToPersonUuidRelationTypePersonAndContactData(person.getUuid(), null, null, null); final var result = relationRbacRepo.findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData(person.getUuid(), null, null, null, null);
// then: // then:
exactlyTheseRelationsAreReturned( exactlyTheseRelationsAreReturned(

View File

@ -31,7 +31,8 @@ import net.hostsharing.hsadminng.hs.office.scenarios.partner.CreatePartner;
import net.hostsharing.hsadminng.hs.office.scenarios.partner.DeletePartner; import net.hostsharing.hsadminng.hs.office.scenarios.partner.DeletePartner;
import net.hostsharing.hsadminng.hs.office.scenarios.person.ShouldUpdatePersonData; import net.hostsharing.hsadminng.hs.office.scenarios.person.ShouldUpdatePersonData;
import net.hostsharing.hsadminng.hs.office.scenarios.subscription.RemoveOperationsContactFromPartner; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.RemoveOperationsContactFromPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeToMailinglist; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeExistingPersonAndContactToMailinglist;
import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeNewPersonAndContactToMailinglist;
import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist; import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist;
import net.hostsharing.hsadminng.hs.scenarios.Produces; import net.hostsharing.hsadminng.hs.scenarios.Produces;
import net.hostsharing.hsadminng.hs.scenarios.Requires; import net.hostsharing.hsadminng.hs.scenarios.Requires;
@ -564,7 +565,7 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Requires("Person: Test AG") @Requires("Person: Test AG")
@Produces("Subscription: Michael Miller to operations-announce") @Produces("Subscription: Michael Miller to operations-announce")
void shouldSubscribeNewPersonAndContactToMailinglist() { void shouldSubscribeNewPersonAndContactToMailinglist() {
new SubscribeToMailinglist(scenarioTest) new SubscribeNewPersonAndContactToMailinglist(scenarioTest)
// TODO.spec: do we need the personType? or is an operational contact always a natural person? what about distribution lists? // TODO.spec: do we need the personType? or is an operational contact always a natural person? what about distribution lists?
.given("partnerPersonTradeName", "Test AG") .given("partnerPersonTradeName", "Test AG")
.given("subscriberFamilyName", "Miller") .given("subscriberFamilyName", "Miller")
@ -578,7 +579,22 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Test @Test
@Order(5001) @Order(5001)
@Requires("Subscription: Michael Miller to operations-announce") @Requires("Subscription: Michael Miller to operations-announce")
void shouldUnsubscribeNewPersonAndContactToMailinglist() { @Produces("Subscription: Michael Miller to operations-discussion")
void shouldSubscribeExistingPersonAndContactToMailinglist() {
new SubscribeExistingPersonAndContactToMailinglist(scenarioTest)
.given("partnerPersonTradeName", "Test AG")
.given("subscriberFamilyName", "Miller")
.given("subscriberGivenName", "Michael")
.given("subscriberEMailAddress", "michael.miller@example.org")
.given("mailingList", "operations-discussion")
.doRun()
.keep();
}
@Test
@Order(5002)
@Requires("Subscription: Michael Miller to operations-announce")
void shouldUnsubscribePersonAndContactFromMailinglist() {
new UnsubscribeFromMailinglist(scenarioTest) new UnsubscribeFromMailinglist(scenarioTest)
.given("mailingList", "operations-announce") .given("mailingList", "operations-announce")
.given("subscriberEMailAddress", "michael.miller@example.org") .given("subscriberEMailAddress", "michael.miller@example.org")

View File

@ -0,0 +1,54 @@
package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
import io.restassured.http.ContentType;
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;
import static org.springframework.http.HttpStatus.OK;
public class SubscribeExistingPersonAndContactToMailinglist extends UseCase<SubscribeExistingPersonAndContactToMailinglist> {
public SubscribeExistingPersonAndContactToMailinglist(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Person: %{partnerPersonTradeName}", () ->
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
obtain(
"Person: %{subscriberGivenName} %{subscriberFamilyName}", () ->
httpGet("/api/hs/office/persons?name=%{subscriberFamilyName}")
.expecting(HttpStatus.OK).expecting(ContentType.JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In real scenarios there are most likely multiple results and you have to choose the right one."
);
obtain("Contact: %{subscriberEMailAddress}", () ->
httpGet("/api/hs/office/contacts?emailAddress=%{subscriberEMailAddress}")
.expecting(HttpStatus.OK).expecting(ContentType.JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In real scenarios there are most likely multiple results and you have to choose the right one."
);
return httpPost("/api/hs/office/relations", usingJsonBody("""
{
"type": "SUBSCRIBER",
"mark": ${mailingList},
"anchor.uuid": ${Person: %{partnerPersonTradeName}},
"holder.uuid": ${Person: %{subscriberGivenName} %{subscriberFamilyName}},
"contact.uuid": ${Contact: %{subscriberEMailAddress}}
}
"""))
.expecting(CREATED).expecting(JSON);
}
}

View File

@ -0,0 +1,46 @@
package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
public class SubscribeNewPersonAndContactToMailinglist extends UseCase<SubscribeNewPersonAndContactToMailinglist> {
public SubscribeNewPersonAndContactToMailinglist(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Person: %{partnerPersonTradeName}", () ->
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
return httpPost("/api/hs/office/relations", usingJsonBody("""
{
"type": "SUBSCRIBER",
"mark": ${mailingList},
"anchor.uuid": ${Person: %{partnerPersonTradeName}},
"holder": {
"personType": "NATURAL_PERSON",
"familyName": ${subscriberFamilyName},
"givenName": ${subscriberGivenName}
},
"contact": {
"caption": "%{subscriberGivenName} %{subscriberFamilyName}",
"emailAddresses": {
"main": ${subscriberEMailAddress}
}
}
}
"""))
.expecting(CREATED).expecting(JSON);
}
}

View File

@ -1,62 +0,0 @@
package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
public class SubscribeToMailinglist extends UseCase<SubscribeToMailinglist> {
public SubscribeToMailinglist(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Person: %{partnerPersonTradeName}", () ->
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
obtain("Person: %{subscriberGivenName} %{subscriberFamilyName}", () ->
httpPost("/api/hs/office/persons", usingJsonBody("""
{
"personType": "NATURAL_PERSON",
"familyName": ${subscriberFamilyName},
"givenName": ${subscriberGivenName}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
);
obtain("Contact: %{subscriberGivenName} %{subscriberFamilyName}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": "%{subscriberGivenName} %{subscriberFamilyName}",
"emailAddresses": {
"main": ${subscriberEMailAddress}
}
}
"""))
.expecting(CREATED).expecting(JSON)
);
return httpPost("/api/hs/office/relations", usingJsonBody("""
{
"type": "SUBSCRIBER",
"mark": ${mailingList},
"anchor.uuid": ${Person: %{partnerPersonTradeName}},
"holder.uuid": ${Person: %{subscriberGivenName} %{subscriberFamilyName}},
"contact.uuid": ${Contact: %{subscriberGivenName} %{subscriberFamilyName}}
}
"""))
.expecting(CREATED).expecting(JSON);
}
}