From c3195662dd31d664bf3d8a0f8db5149c6f4a8a4e Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 3 Oct 2022 11:09:36 +0200 Subject: [PATCH] adds HsOfficePartner --- .aliases | 1 + README.md | 64 ++- .../hsadminng/errors/DisplayName.java | 12 + .../RestResponseEntityExceptionHandler.java | 38 ++ .../office/contact/HsOfficeContactEntity.java | 2 + .../debitor/HsOfficeDebitorController.java | 150 ++++++ .../office/debitor/HsOfficeDebitorEntity.java | 57 ++ .../debitor/HsOfficeDebitorEntityPatcher.java | 41 ++ .../debitor/HsOfficeDebitorRepository.java | 39 ++ .../partner/HsOfficePartnerController.java | 2 + .../office/partner/HsOfficePartnerEntity.java | 2 + .../office/person/HsOfficePersonEntity.java | 6 +- .../hs-office/api-mappings.yaml | 2 + .../hs-office/hs-office-debitor-schemas.yaml | 70 +++ .../hs-office-debitors-with-uuid.yaml | 83 +++ .../hs-office/hs-office-debitors.yaml | 62 +++ .../api-definition/hs-office/hs-office.yaml | 9 +- .../218-hs-office-person-test-data.sql | 1 + .../228-hs-office-partner-test-data.sql | 3 +- .../db/changelog/270-hs-office-debitor.sql | 18 + .../changelog/273-hs-office-debitor-rbac.md | 24 + .../changelog/273-hs-office-debitor-rbac.sql | 192 +++++++ .../278-hs-office-debitor-test-data.sql | 52 ++ .../db/changelog/db.changelog-master.yaml | 6 + .../hsadminng/PatchUnitTestBase.java | 2 +- .../hsadminng/arch/ArchitectureTest.java | 8 +- ...esponseEntityExceptionHandlerUnitTest.java | 92 ++++ ...OfficeDebitorControllerAcceptanceTest.java | 505 ++++++++++++++++++ .../HsOfficeDebitorEntityPatcherUnitTest.java | 112 ++++ .../debitor/HsOfficeDebitorEntityTest.java | 40 ++ ...fficeDebitorRepositoryIntegrationTest.java | 464 ++++++++++++++++ ...OfficePartnerControllerAcceptanceTest.java | 4 + ...sOfficePersonControllerAcceptanceTest.java | 44 +- .../person/HsOfficePersonEntityUnitTest.java | 4 +- ...OfficePersonRepositoryIntegrationTest.java | 6 +- 35 files changed, 2182 insertions(+), 35 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/errors/DisplayName.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntity.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcher.java create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java create mode 100644 src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml create mode 100644 src/main/resources/api-definition/hs-office/hs-office-debitors-with-uuid.yaml create mode 100644 src/main/resources/api-definition/hs-office/hs-office-debitors.yaml create mode 100644 src/main/resources/db/changelog/270-hs-office-debitor.sql create mode 100644 src/main/resources/db/changelog/273-hs-office-debitor-rbac.md create mode 100644 src/main/resources/db/changelog/273-hs-office-debitor-rbac.sql create mode 100644 src/main/resources/db/changelog/278-hs-office-debitor-test-data.sql create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcherUnitTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityTest.java create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java diff --git a/.aliases b/.aliases index 6d64df9a..6fe8c0e5 100644 --- a/.aliases +++ b/.aliases @@ -25,3 +25,4 @@ alias pg-sql-reset='pg-sql-stop; pg-sql-remove; pg-sql-run' alias pg-sql-backup='docker exec -i hsadmin-ng-postgres /usr/bin/pg_dump --clean --create -U postgres postgres | gzip -9' alias pg-sql-restore='gunzip --stdout | docker exec -i hsadmin-ng-postgres psql -U postgres -d postgres' +alias fp='grep -r '@Accepts' src | sed -e 's/^.*@/@/g' | sort -u | wc -l' diff --git a/README.md b/README.md index 2ed82547..e1a27f73 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ For architecture consider the files in the `doc` and `adr` folder. - [PostgreSQL Server](#postgresql-server) - [Markdown](#markdown) - [Render Markdown embedded PlantUML](#render-markdown-embedded-plantuml) + - [Render Markdown Embedded Mermaid Diagrams](#render-markdown-embedded-mermaid-diagrams) - [IDE Specific Settings](#ide-specific-settings) - [IntelliJ IDEA](#intellij-idea) - [Other Tools](#other-tools) @@ -27,6 +28,7 @@ For architecture consider the files in the `doc` and `adr` folder. - [Dependency-License-Compatibility](#dependency-license-compatibility) - [Dependency Version Upgrade](#dependency-version-upgrade) - [How To ...](#how-to-...) + - [How to Configure .pgpass for the Default PostgreSQL Database?](#how-to-configure-.pgpass-for-the-default-postgresql-database?) - [How to Run the Tests Against a Local User-Space Podman Daemon?](#how-to-run-the-tests-against-a-local-user-space-podman-daemon?) - [Install and Run Podman](#install-and-run-podman) - [Use the Command Line to Run the Tests Against the Podman Daemon ](#use-the-command-line-to-run-the-tests-against-the-podman-daemon-) @@ -35,7 +37,8 @@ For architecture consider the files in the `doc` and `adr` folder. - [How to Run the Tests Against a Remote Podman or Docker Daemon?](#how-to-run-the-tests-against-a-remote-podman-or-docker-daemon?) - [How to Run the Application on a Different Port?](#how-to-run-the-application-on-a-different-port?) - [How to Use a Persistent Database for Integration Tests?](#how-to-use-a-persistent-database-for-integration-tests?) -- [How to Amend Liquibase SQL Changesets?](#how-to-amend-liquibase-sql-changesets?) + - [How to Amend Liquibase SQL Changesets?](#how-to-amend-liquibase-sql-changesets?) + - [How to Re-Generate Spring-Controller-Interfaces from OpenAPI specs?](#how-to-re-generate-spring-controller-interfaces-from-openapi-specs?) - [Further Documentation](#further-documentation) @@ -190,6 +193,12 @@ Again, given the container is running, to restore the backup from ~/backup, run: To generate the TOC (Table of Contents), a little bash script from a [Blog Article](https://medium.com/@acrodriguez/one-liner-to-generate-a-markdown-toc-f5292112fd14) was used. +Given this is in PATH as `md-toc`, use: + +```shell +md-toc you: Can you see this diagram? +you -> me: Sorry, I don't :-( +me -> you: Install some tooling! +@enduml + +```mermaid +graph TD; + A[Can you see this diagram?]; + A --> yes; + A --> no; + no --> F[Follow the instructions below!] + F --> yes + yes --> E[Then everything is fine.] +``` + +If not, you need to install some tooling. + +##### for IntelliJ IDEA (or derived products) + +You just need the bundled Markdown plugin enabled and install and activate the Mermaid plugin in its [settings](jetbrains://idea/settings?name=Languages+%26+Frameworks--Markdown). + + +##### for other IDEs / command-line / operating systems + +If you have figured out how it works, please add instructions above this section. + ### IDE Specific Settings #### IntelliJ IDEA @@ -643,8 +685,8 @@ If the persistent database and the temporary database show different results, on 2. You might have changes in the database which interfere with the tests, e.g. from a previous run of tests or manually applied. It's best to run `pg-sql-reset && gw bootRun` before each test run, to have a clean database. - -## How to Amend Liquibase SQL Changesets? + +### How to Amend Liquibase SQL Changesets? Liquibase changesets are meant to be immutable and based on each other. That means, once a changeset is written, it never changes, not even a whitespace or comment. @@ -668,6 +710,22 @@ gw bootRun **⚠** Just don't forget switching to the migration mode, once there is a production database! +### How to Re-Generate Spring-Controller-Interfaces from OpenAPI specs? + +The API is described as OpenAPI specifications in `src/main/resources/api-definition/`. + +Once generated, the interfaces for the Spring-Controllers can be found in `build/generated/sources/openapi`. + +These interfaces have to be implemented by subclasses named `*Controller`. + +All gradle tasks which need the generated interfaces depend on the Gradle task `processSpring` which controls the code generation. +It can also be executed directly: + +```shell +gw processSpring +``` + + ## Further Documentation - the `doc` directory contains architecture concepts and a glossary diff --git a/src/main/java/net/hostsharing/hsadminng/errors/DisplayName.java b/src/main/java/net/hostsharing/hsadminng/errors/DisplayName.java new file mode 100644 index 00000000..8c5eed4c --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/errors/DisplayName.java @@ -0,0 +1,12 @@ +package net.hostsharing.hsadminng.errors; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface DisplayName { + String value() default ""; +} diff --git a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java index a0570138..beef530c 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java @@ -6,15 +6,18 @@ import org.springframework.core.NestedExceptionUtils; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.orm.jpa.JpaObjectRetrievalFailureException; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import javax.persistence.EntityNotFoundException; import java.time.LocalDateTime; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.regex.Pattern; @ControllerAdvice public class RestResponseEntityExceptionHandler @@ -42,6 +45,41 @@ public class RestResponseEntityExceptionHandler return errorResponse(request, HttpStatus.NOT_FOUND, message); } + @ExceptionHandler({ JpaObjectRetrievalFailureException.class, EntityNotFoundException.class }) + protected ResponseEntity handleJpaObjectRetrievalFailureException( + final RuntimeException exc, final WebRequest request) { + final var message = + userReadableEntityClassName( + firstLine(NestedExceptionUtils.getMostSpecificCause(exc).getMessage())); + return errorResponse(request, HttpStatus.BAD_REQUEST, message); + } + + private String userReadableEntityClassName(final String exceptionMessage) { + final var regex = "(net.hostsharing.hsadminng.[a-z0-9_.]*.[A-Za-z0-9_$]*Entity) "; + final var pattern = Pattern.compile(regex); + final var matcher = pattern.matcher(exceptionMessage); + if (matcher.find()) { + final var entityName = matcher.group(1); + final var entityClass = resolveClassOrNull(entityName); + if (entityClass != null ) { + return (entityClass.isAnnotationPresent(DisplayName.class) + ? exceptionMessage.replace(entityName, entityClass.getAnnotation(DisplayName.class).value()) + : exceptionMessage.replace(entityName, entityClass.getSimpleName())) + .replace(" with id ", " with uuid "); + } + + } + return exceptionMessage; + } + + private static Class resolveClassOrNull(final String entityName) { + try { + return ClassLoader.getSystemClassLoader().loadClass(entityName); + } catch (ClassNotFoundException e) { + return null; + } + } + @ExceptionHandler(Throwable.class) protected ResponseEntity handleOtherExceptions( final Throwable exc, final WebRequest request) { diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java index 23ee3f6e..8de19a15 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java @@ -2,6 +2,7 @@ package net.hostsharing.hsadminng.hs.office.contact; import lombok.*; import lombok.experimental.FieldNameConstants; +import net.hostsharing.hsadminng.errors.DisplayName; import net.hostsharing.hsadminng.Stringify; import net.hostsharing.hsadminng.Stringifyable; @@ -21,6 +22,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify; @NoArgsConstructor @AllArgsConstructor @FieldNameConstants +@DisplayName("Contact") public class HsOfficeContactEntity implements Stringifyable { private static Stringify toString = stringify(HsOfficeContactEntity.class, "contact") diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java new file mode 100644 index 00000000..b9c7fccb --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java @@ -0,0 +1,150 @@ +package net.hostsharing.hsadminng.hs.office.debitor; + +import net.hostsharing.hsadminng.Mapper; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeDebitorsApi; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntityPatcher; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import javax.persistence.EntityManager; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; +import java.util.function.BiConsumer; + +import static net.hostsharing.hsadminng.Mapper.map; + +@RestController + +public class HsOfficeDebitorController implements HsOfficeDebitorsApi { + + @Autowired + private Context context; + + @Autowired + private HsOfficeDebitorRepository debitorRepo; + + @Autowired + private HsOfficePartnerRepository partnerRepo; + + @Autowired + private HsOfficeContactRepository contactRepo; + + @Autowired + private EntityManager em; + + @Override + @Transactional(readOnly = true) + public ResponseEntity> listDebitors( + final String currentUser, + final String assumedRoles, + final String name, + final Integer debitorNumber) { + context.define(currentUser, assumedRoles); + + final var entities = debitorNumber != null + ? debitorRepo.findDebitorByDebitorNumber(debitorNumber) + : debitorRepo.findDebitorByOptionalNameLike(name); + + final var resources = Mapper.mapList(entities, HsOfficeDebitorResource.class, + DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.ok(resources); + } + + @Override + @Transactional + public ResponseEntity addDebitor( + final String currentUser, + final String assumedRoles, + final HsOfficeDebitorInsertResource body) { + + context.define(currentUser, assumedRoles); + + final var entityToSave = map(body, HsOfficeDebitorEntity.class, DEBITOR_RESOURCE_TO_ENTITY_POSTMAPPER); + entityToSave.setUuid(UUID.randomUUID()); + + final var saved = debitorRepo.save(entityToSave); + + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/office/debitors/{id}") + .buildAndExpand(entityToSave.getUuid()) + .toUri(); + final var mapped = map(saved, HsOfficeDebitorResource.class, + DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER); + return ResponseEntity.created(uri).body(mapped); + } + + @Override + @Transactional(readOnly = true) + public ResponseEntity getDebitorByUuid( + final String currentUser, + final String assumedRoles, + final UUID debitorUuid) { + + context.define(currentUser, assumedRoles); + + final var result = debitorRepo.findByUuid(debitorUuid); + if (result.isEmpty()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(map(result.get(), HsOfficeDebitorResource.class, DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER)); + } + + @Override + @Transactional + public ResponseEntity deleteDebitorByUuid( + final String currentUser, + final String assumedRoles, + final UUID debitorUuid) { + context.define(currentUser, assumedRoles); + + final var result = debitorRepo.deleteByUuid(debitorUuid); + if (result == 0) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.noContent().build(); + } + + @Override + @Transactional + public ResponseEntity patchDebitor( + final String currentUser, + final String assumedRoles, + final UUID debitorUuid, + final HsOfficeDebitorPatchResource body) { + + context.define(currentUser, assumedRoles); + + final var current = debitorRepo.findByUuid(debitorUuid).orElseThrow(); + + new HsOfficeDebitorEntityPatcher(em, current).apply(body); + + final var saved = debitorRepo.save(current); + final var mapped = map(saved, HsOfficeDebitorResource.class); + return ResponseEntity.ok(mapped); + } + + + final BiConsumer DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { + resource.setPartner(map(entity.getPartner(), HsOfficePartnerResource.class)); + resource.setBillingContact(map(entity.getBillingContact(), HsOfficeContactResource.class)); + }; + + final BiConsumer DEBITOR_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + entity.setPartner(em.getReference(HsOfficePartnerEntity.class, resource.getPartnerUuid())); + entity.setBillingContact(em.getReference(HsOfficeContactEntity.class, resource.getBillingContactUuid())); + }; +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntity.java new file mode 100644 index 00000000..dd7c39ee --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntity.java @@ -0,0 +1,57 @@ +package net.hostsharing.hsadminng.hs.office.debitor; + +import lombok.*; +import net.hostsharing.hsadminng.errors.DisplayName; +import net.hostsharing.hsadminng.Stringify; +import net.hostsharing.hsadminng.Stringifyable; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; + +import javax.persistence.*; +import java.util.UUID; + +import static net.hostsharing.hsadminng.Stringify.stringify; + +@Entity +@Table(name = "hs_office_debitor_rv") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DisplayName("Debitor") +public class HsOfficeDebitorEntity implements Stringifyable { + + private static Stringify stringify = + stringify(HsOfficeDebitorEntity.class, "debitor") + .withProp(HsOfficeDebitorEntity::getDebitorNumber) + .withProp(HsOfficeDebitorEntity::getPartner) + .withSeparator(": ") + .quotedValues(false); + + private @Id UUID uuid; + + @ManyToOne + @JoinColumn(name = "partneruuid") + private HsOfficePartnerEntity partner; + + private @Column(name = "debitornumber") Integer debitorNumber; + + @ManyToOne + @JoinColumn(name = "billingcontactuuid") + private HsOfficeContactEntity billingContact; + + private @Column(name = "vatid") String vatId; + private @Column(name = "vatcountrycode") String vatCountryCode; + private @Column(name = "vatbusiness") boolean vatBusiness; + + @Override + public String toString() { + return stringify.apply(this); + } + + @Override + public String toShortString() { + return debitorNumber.toString(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcher.java new file mode 100644 index 00000000..5fdef0e6 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcher.java @@ -0,0 +1,41 @@ +package net.hostsharing.hsadminng.hs.office.debitor; + +import net.hostsharing.hsadminng.EntityPatcher; +import net.hostsharing.hsadminng.OptionalFromJson; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorPatchResource; + +import javax.persistence.EntityManager; + +class HsOfficeDebitorEntityPatcher implements EntityPatcher { + + private final EntityManager em; + private final HsOfficeDebitorEntity entity; + + HsOfficeDebitorEntityPatcher( + final EntityManager em, + final HsOfficeDebitorEntity entity) { + this.em = em; + this.entity = entity; + } + + @Override + public void apply(final HsOfficeDebitorPatchResource resource) { + OptionalFromJson.of(resource.getBillingContactUuid()).ifPresent(newValue -> { + verifyNotNull(newValue, "billingContact"); + entity.setBillingContact(em.getReference(HsOfficeContactEntity.class, newValue)); + }); + OptionalFromJson.of(resource.getVatId()).ifPresent(entity::setVatId); + OptionalFromJson.of(resource.getVatCountryCode()).ifPresent(entity::setVatCountryCode); + OptionalFromJson.of(resource.getVatBusiness()).ifPresent(newValue -> { + verifyNotNull(newValue, "vatBusiness"); + entity.setVatBusiness(newValue); + }); + } + + private void verifyNotNull(final Object newValue, final String propertyName) { + if (newValue == null) { + throw new IllegalArgumentException("property '" + propertyName + "' must not be null"); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java new file mode 100644 index 00000000..686f42ca --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepository.java @@ -0,0 +1,39 @@ +package net.hostsharing.hsadminng.hs.office.debitor; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsOfficeDebitorRepository extends Repository { + + Optional findByUuid(UUID id); + + @Query(""" + SELECT debitor FROM HsOfficeDebitorEntity debitor + WHERE debitor.debitorNumber = :debitorNumber + """) + List findDebitorByDebitorNumber(int debitorNumber); + + @Query(""" + SELECT debitor FROM HsOfficeDebitorEntity debitor + JOIN HsOfficePartnerEntity partner ON partner.uuid = debitor.partner + JOIN HsOfficePersonEntity person ON person.uuid = partner.person + JOIN HsOfficeContactEntity contact ON contact.uuid = debitor.billingContact + WHERE :name is null + OR partner.birthName like concat(:name, '%') + OR person.tradeName like concat(:name, '%') + OR person.familyName like concat(:name, '%') + OR person.givenName like concat(:name, '%') + OR contact.label like concat(:name, '%') + """) + List findDebitorByOptionalNameLike(String name); + + HsOfficeDebitorEntity save(final HsOfficeDebitorEntity entity); + + long count(); + + int deleteByUuid(UUID uuid); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java index 40356eea..a3463bd9 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java @@ -65,6 +65,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi { final var entityToSave = mapToHsOfficePartnerEntity(body); entityToSave.setUuid(UUID.randomUUID()); + // TODO.impl: use getReference entityToSave.setContact(contactRepo.findByUuid(body.getContactUuid()).orElseThrow( () -> new NoSuchElementException("cannot find contact uuid " + body.getContactUuid()) )); @@ -141,6 +142,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi { resource.setContact(map(entity.getContact(), HsOfficeContactResource.class)); }; + // TODO.impl: user postmapper + getReference private HsOfficePartnerEntity mapToHsOfficePartnerEntity(final HsOfficePartnerInsertResource resource) { final var entity = new HsOfficePartnerEntity(); entity.setBirthday(resource.getBirthday()); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java index 58e7937a..f29ab270 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.partner; import lombok.*; +import net.hostsharing.hsadminng.errors.DisplayName; import net.hostsharing.hsadminng.Stringify; import net.hostsharing.hsadminng.Stringifyable; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; @@ -19,6 +20,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify; @Builder @NoArgsConstructor @AllArgsConstructor +@DisplayName("Partner") public class HsOfficePartnerEntity implements Stringifyable { private static Stringify stringify = stringify(HsOfficePartnerEntity.class, "partner") diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java index cadcd4ab..8b15ecbb 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java @@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.office.person; import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType; import lombok.*; import lombok.experimental.FieldNameConstants; +import net.hostsharing.hsadminng.errors.DisplayName; import net.hostsharing.hsadminng.Stringify; import net.hostsharing.hsadminng.Stringifyable; import org.apache.commons.lang3.StringUtils; @@ -26,6 +27,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify; @NoArgsConstructor @AllArgsConstructor @FieldNameConstants +@DisplayName("Person") public class HsOfficePersonEntity implements Stringifyable { private static Stringify toString = stringify(HsOfficePersonEntity.class, "person") @@ -50,10 +52,6 @@ public class HsOfficePersonEntity implements Stringifyable { @Column(name = "givenname") private String givenName; - public String getDisplayName() { - return toShortString(); - } - @Override public String toString() { return toString.apply(this); diff --git a/src/main/resources/api-definition/hs-office/api-mappings.yaml b/src/main/resources/api-definition/hs-office/api-mappings.yaml index c77f5c86..44ea0ea3 100644 --- a/src/main/resources/api-definition/hs-office/api-mappings.yaml +++ b/src/main/resources/api-definition/hs-office/api-mappings.yaml @@ -20,3 +20,5 @@ map: null: org.openapitools.jackson.nullable.JsonNullable /api/hs/office/relationships/{relationshipUUID}: null: org.openapitools.jackson.nullable.JsonNullable + /api/hs/office/debitors/{debitorUUID}: + null: org.openapitools.jackson.nullable.JsonNullable diff --git a/src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml new file mode 100644 index 00000000..88c291a8 --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-debitor-schemas.yaml @@ -0,0 +1,70 @@ + +components: + + schemas: + + HsOfficeDebitor: + type: object + properties: + uuid: + type: string + format: uuid + debitorNumber: + type: integer + format: int32 + minimum: 10000 + maximum: 99999 + partner: + $ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner' + billingContact: + $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' + vatId: + type: string + vatCountryCode: + type: string + pattern: '^[A_Z][A-Z]$' + vatBusiness: + type: boolean + + HsOfficeDebitorPatch: + type: object + properties: + billingContactUuid: + type: string + format: uuid + nullable: true + vatId: + type: string + nullable: true + vatCountryCode: + type: string + pattern: '^[A_Z][A-Z]$' + nullable: true + vatBusiness: + type: boolean + nullable: true + + HsOfficeDebitorInsert: + type: object + properties: + partnerUuid: + type: string + format: uuid + billingContactUuid: + type: string + format: uuid + debitorNumber: + type: integer + format: int32 + minimum: 10000 + maximum: 99999 + vatId: + type: string + vatCountryCode: + type: string + pattern: '^[A_Z][A-Z]$' + vatBusiness: + type: boolean + required: + - partnerUuid + - billingContactUuid diff --git a/src/main/resources/api-definition/hs-office/hs-office-debitors-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-debitors-with-uuid.yaml new file mode 100644 index 00000000..3789879d --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-debitors-with-uuid.yaml @@ -0,0 +1,83 @@ +get: + tags: + - hs-office-debitors + description: 'Fetch a single debitor by its uuid, if visible for the current subject.' + operationId: getDebitorByUuid + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: debitorUUID + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the debitor to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + +patch: + tags: + - hs-office-debitors + description: 'Updates a single debitor by its uuid, if permitted for the current subject.' + operationId: patchDebitor + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: debitorUUID + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + 'application/json': + schema: + $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorPatch' + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - hs-office-debitors + description: 'Delete a single debitor by its uuid, if permitted for the current subject.' + operationId: deleteDebitorByUuid + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: debitorUUID + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the debitor to delete. + responses: + "204": + description: No Content + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + "404": + $ref: './error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/hs-office/hs-office-debitors.yaml b/src/main/resources/api-definition/hs-office/hs-office-debitors.yaml new file mode 100644 index 00000000..c35deb7a --- /dev/null +++ b/src/main/resources/api-definition/hs-office/hs-office-debitors.yaml @@ -0,0 +1,62 @@ +get: + summary: Returns a list of (optionally filtered) debitors. + description: Returns the list of (optionally filtered) debitors which are visible to the current user or any of it's assumed roles. + tags: + - hs-office-debitors + operationId: listDebitors + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + - name: name + in: query + required: false + schema: + type: string + description: Prefix of name properties from person or contact to filter the results. + - name: debitorNumber + in: query + required: false + schema: + type: integer + description: Debitor number of the requested debitor. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new debitor. + tags: + - hs-office-debitors + operationId: addDebitor + parameters: + - $ref: './auth.yaml#/components/parameters/currentUser' + - $ref: './auth.yaml#/components/parameters/assumedRoles' + requestBody: + content: + 'application/json': + schema: + $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorInsert' + required: true + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor' + "401": + $ref: './error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: './error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: './error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-office/hs-office.yaml b/src/main/resources/api-definition/hs-office/hs-office.yaml index 856209ab..127ae237 100644 --- a/src/main/resources/api-definition/hs-office/hs-office.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office.yaml @@ -35,7 +35,6 @@ paths: $ref: "./hs-office-persons-with-uuid.yaml" - # Relationships /api/hs/office/relationships: @@ -44,3 +43,11 @@ paths: /api/hs/office/relationships/{relationshipUUID}: $ref: "./hs-office-relationships-with-uuid.yaml" + + # Debitors + + /api/hs/office/debitors: + $ref: "./hs-office-debitors.yaml" + + /api/hs/office/debitors/{debitorUUID}: + $ref: "./hs-office-debitors-with-uuid.yaml" diff --git a/src/main/resources/db/changelog/218-hs-office-person-test-data.sql b/src/main/resources/db/changelog/218-hs-office-person-test-data.sql index f761d528..d6567483 100644 --- a/src/main/resources/db/changelog/218-hs-office-person-test-data.sql +++ b/src/main/resources/db/changelog/218-hs-office-person-test-data.sql @@ -63,6 +63,7 @@ do language plpgsql $$ call createHsOfficePersonTestData('NATURAL', null, 'Smith', 'Peter'); call createHsOfficePersonTestData('LEGAL', 'Second e.K.', 'Sandra', 'Miller'); call createHsOfficePersonTestData('SOLE_REPRESENTATION', 'Third OHG'); + call createHsOfficePersonTestData('SOLE_REPRESENTATION', 'Fourth e.G.'); call createHsOfficePersonTestData('JOINT_REPRESENTATION', 'Erben Bessler', 'Mel', 'Bessler'); call createHsOfficePersonTestData('NATURAL', null, 'Bessler', 'Anita'); call createHsOfficePersonTestData('NATURAL', null, 'Winkler', 'Paul'); diff --git a/src/main/resources/db/changelog/228-hs-office-partner-test-data.sql b/src/main/resources/db/changelog/228-hs-office-partner-test-data.sql index 60a5de4e..d2e15861 100644 --- a/src/main/resources/db/changelog/228-hs-office-partner-test-data.sql +++ b/src/main/resources/db/changelog/228-hs-office-partner-test-data.sql @@ -64,10 +64,9 @@ end; $$; do language plpgsql $$ begin call createHsOfficePartnerTestData('First GmbH', 'first contact'); - call createHsOfficePartnerTestData('Second e.K.', 'second contact'); - call createHsOfficePartnerTestData('Third OHG', 'third contact'); + call createHsOfficePartnerTestData('Fourth e.G.', 'forth contact'); end; $$; --// diff --git a/src/main/resources/db/changelog/270-hs-office-debitor.sql b/src/main/resources/db/changelog/270-hs-office-debitor.sql new file mode 100644 index 00000000..7582d709 --- /dev/null +++ b/src/main/resources/db/changelog/270-hs-office-debitor.sql @@ -0,0 +1,18 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-office-debitor-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create table hs_office_debitor +( + uuid uuid unique references RbacObject (uuid) initially deferred, + partnerUuid uuid not null references hs_office_partner(uuid), + debitorNumber numeric(5) not null, + billingContactUuid uuid not null references hs_office_contact(uuid), + vatId varchar(24), -- TODO.spec: here or in person? + vatCountryCode varchar(2), + vatBusiness boolean not null -- TODO.spec: more of such? + -- TODO.impl: SEPA-mandate + bank account +); +--// diff --git a/src/main/resources/db/changelog/273-hs-office-debitor-rbac.md b/src/main/resources/db/changelog/273-hs-office-debitor-rbac.md new file mode 100644 index 00000000..13709f6a --- /dev/null +++ b/src/main/resources/db/changelog/273-hs-office-debitor-rbac.md @@ -0,0 +1,24 @@ +### hs_office_debitor RBAC Roles + +```mermaid +graph TD; + %% role:debitor.owner + role:debitor.owner --> perm:debitor.*; + role:global.admin --> role:debitor.owner; + + %% role:debitor.admin + role:debitor.admin --> perm:debitor.edit; + role:debitor.owner --> role:debitor.admin; + + %% role:debitor.tenant + role:debitor.tenant --> perm:debitor.view; + %% super-roles + role:debitor.admin --> role:debitor.tenant; + role:partner.admin --> role:debitor.tenant; + role:person.admin --> role:debitor.tenant; + role:contact.admin --> role:debitor.tenant; + %% sub-roles + role:debitor.tenant --> role:partner.tenant; + role:debitor.tenant --> role:person.tenant; + role:debitor.tenant --> role:contact.tenant; +``` diff --git a/src/main/resources/db/changelog/273-hs-office-debitor-rbac.sql b/src/main/resources/db/changelog/273-hs-office-debitor-rbac.sql new file mode 100644 index 00000000..356fe094 --- /dev/null +++ b/src/main/resources/db/changelog/273-hs-office-debitor-rbac.sql @@ -0,0 +1,192 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-office-debitor-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_office_debitor'); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsOfficeDebitor', 'hs_office_debitor'); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-ROLES-CREATION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates and updates the roles and their assignments for debitor entities. + */ + +create or replace function hsOfficeDebitorRbacRolesTrigger() + returns trigger + language plpgsql + strict as $$ +declare + hsOfficeDebitorTenant RbacRoleDescriptor; + ownerRole uuid; + adminRole uuid; + oldPartner hs_office_partner; + newPartner hs_office_partner; + newPerson hs_office_person; + oldContact hs_office_contact; + newContact hs_office_contact; +begin + + hsOfficeDebitorTenant := hsOfficeDebitorTenant(NEW); + + select * from hs_office_partner as p where p.uuid = NEW.partnerUuid into newPartner; + select * from hs_office_person as p where p.uuid = newPartner.personUuid into newPerson; + select * from hs_office_contact as c where c.uuid = NEW.billingContactUuid into newContact; + + if TG_OP = 'INSERT' then + + -- the owner role with full access for the global admins + ownerRole = createRole( + hsOfficeDebitorOwner(NEW), + grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['*']), + beneathRole(globalAdmin()) + ); + + -- the admin role with full access for owner + adminRole = createRole( + hsOfficeDebitorAdmin(NEW), + grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['edit']), + beneathRole(ownerRole) + ); + + -- the tenant role for those related users who can view the data + perform createRole( + hsOfficeDebitorTenant, + grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view']), + beneathRoles(array[ + hsOfficeDebitorAdmin(NEW), + hsOfficePartnerAdmin(newPartner), + hsOfficePersonAdmin(newPerson), + hsOfficeContactAdmin(newContact)]), + withSubRoles(array[ + hsOfficePartnerTenant(newPartner), + hsOfficePersonTenant(newPerson), + hsOfficeContactTenant(newContact)]) + ); + + elsif TG_OP = 'UPDATE' then + + if OLD.partnerUuid <> NEW.partnerUuid then + select * from hs_office_partner as p where p.uuid = OLD.partnerUuid into oldPartner; + + call revokeRoleFromRole( hsOfficeDebitorTenant, hsOfficePartnerAdmin(oldPartner) ); + call grantRoleToRole( hsOfficeDebitorTenant, hsOfficePartnerAdmin(newPartner) ); + + call revokeRoleFromRole( hsOfficePartnerTenant(oldPartner), hsOfficeDebitorTenant ); + call grantRoleToRole( hsOfficePartnerTenant(newPartner), hsOfficeDebitorTenant ); + end if; + + if OLD.billingContactUuid <> NEW.billingContactUuid then + select * from hs_office_contact as c where c.uuid = OLD.billingContactUuid into oldContact; + + call revokeRoleFromRole( hsOfficeDebitorTenant, hsOfficeContactAdmin(oldContact) ); + call grantRoleToRole( hsOfficeDebitorTenant, hsOfficeContactAdmin(newContact) ); + + call revokeRoleFromRole( hsOfficeContactTenant(oldContact), hsOfficeDebitorTenant ); + call grantRoleToRole( hsOfficeContactTenant(newContact), hsOfficeDebitorTenant ); + end if; + else + raise exception 'invalid usage of TRIGGER'; + end if; + + return NEW; +end; $$; + +/* + An AFTER INSERT TRIGGER which creates the role structure for a new debitor. + */ +create trigger createRbacRolesForHsOfficeDebitor_Trigger + after insert + on hs_office_debitor + for each row +execute procedure hsOfficeDebitorRbacRolesTrigger(); + +/* + An AFTER UPDATE TRIGGER which updates the role structure of a debitor. + */ +create trigger updateRbacRolesForHsOfficeDebitor_Trigger + after update + on hs_office_debitor + for each row +execute procedure hsOfficeDebitorRbacRolesTrigger(); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacIdentityView('hs_office_debitor', $idName$ + '#' || debitorNumber || ':' || + (select idName from hs_office_partner_iv p where p.uuid = target.partnerUuid) + $idName$); +--// + + +-- ============================================================================ +--changeset hs-office-debitor-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_office_debitor', + 'target.debitorNumber', + $updates$ + billingContactUuid = new.billingContactUuid, + vatId = new.vatId, + vatCountryCode = new.vatCountryCode, + vatBusiness = new.vatBusiness + $updates$); +--// + +-- ============================================================================ +--changeset hs-office-debitor-rbac-NEW-DEBITOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +/* + Creates a global permission for new-debitor and assigns it to the hostsharing admins role. + */ +do language plpgsql $$ + declare + addDebitorPermissions uuid[]; + globalObjectUuid uuid; + globalAdminRoleUuid uuid ; + begin + call defineContext('granting global new-debitor permission to global admin role', null, null, null); + + globalAdminRoleUuid := findRoleId(globalAdmin()); + globalObjectUuid := (select uuid from global); + addDebitorPermissions := createPermissions(globalObjectUuid, array ['new-debitor']); + call grantPermissionsToRole(globalAdminRoleUuid, addDebitorPermissions); + end; +$$; + +/** + Used by the trigger to prevent the add-debitor to current user respectively assumed roles. + */ +create or replace function addHsOfficeDebitorNotAllowedForCurrentSubjects() + returns trigger + language PLPGSQL +as $$ +begin + raise exception '[403] new-debitor not permitted for %', + array_to_string(currentSubjects(), ';', 'null'); +end; $$; + +/** + Checks if the user or assumed roles are allowed to create a new debitor. + */ +create trigger hs_office_debitor_insert_trigger + before insert + on hs_office_debitor + for each row + -- TODO.spec: who is allowed to create new debitors + when ( not hasAssumedRole() ) +execute procedure addHsOfficeDebitorNotAllowedForCurrentSubjects(); +--// + diff --git a/src/main/resources/db/changelog/278-hs-office-debitor-test-data.sql b/src/main/resources/db/changelog/278-hs-office-debitor-test-data.sql new file mode 100644 index 00000000..98fa4478 --- /dev/null +++ b/src/main/resources/db/changelog/278-hs-office-debitor-test-data.sql @@ -0,0 +1,52 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-office-debitor-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single debitor test record. + */ +create or replace procedure createHsOfficeDebitorTestData( partnerTradeName varchar, billingContactLabel varchar ) + language plpgsql as $$ +declare + currentTask varchar; + idName varchar; + relatedPartner hs_office_partner; + relatedContact hs_office_contact; + newDebitorNumber numeric(6); +begin + idName := cleanIdentifier( partnerTradeName|| '-' || billingContactLabel); + currentTask := 'creating RBAC test debitor ' || idName; + call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin'); + execute format('set local hsadminng.currentTask to %L', currentTask); + + select partner.* from hs_office_partner partner + join hs_office_person person on person.uuid = partner.personUuid + where person.tradeName = partnerTradeName into relatedPartner; + select c.* from hs_office_contact c where c.label = billingContactLabel into relatedContact; + select coalesce(max(debitorNumber)+1, 10001) from hs_office_debitor into newDebitorNumber; + + raise notice 'creating test debitor: % (#%)', idName, newDebitorNumber; + raise notice '- using partner (%): %', relatedPartner.uuid, relatedPartner; + raise notice '- using billingContact (%): %', relatedContact.uuid, relatedContact; + insert + into hs_office_debitor (uuid, partneruuid, debitornumber, billingcontactuuid, vatbusiness) + values (uuid_generate_v4(), relatedPartner.uuid, newDebitorNumber, relatedContact.uuid, true); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-office-debitor-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call createHsOfficeDebitorTestData('First GmbH', 'first contact'); + call createHsOfficeDebitorTestData('Second e.K.', 'second contact'); + call createHsOfficeDebitorTestData('Third OHG', 'third contact'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 1ed2aa8c..6f9b5434 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -71,3 +71,9 @@ databaseChangeLog: file: db/changelog/233-hs-office-relationship-rbac.sql - include: file: db/changelog/238-hs-office-relationship-test-data.sql + - include: + file: db/changelog/270-hs-office-debitor.sql + - include: + file: db/changelog/273-hs-office-debitor-rbac.sql + - include: + file: db/changelog/278-hs-office-debitor-test-data.sql diff --git a/src/test/java/net/hostsharing/hsadminng/PatchUnitTestBase.java b/src/test/java/net/hostsharing/hsadminng/PatchUnitTestBase.java index 0c8eddbf..9d9ad888 100644 --- a/src/test/java/net/hostsharing/hsadminng/PatchUnitTestBase.java +++ b/src/test/java/net/hostsharing/hsadminng/PatchUnitTestBase.java @@ -132,7 +132,7 @@ public abstract class PatchUnitTestBase { protected abstract EntityPatcher createPatcher(final E entity); - @SuppressWarnings("rawtypes") + @SuppressWarnings("types") protected abstract Stream propertyTestDescriptors(); private Stream propertyTestCases() { diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java index da0709c4..e298a7c8 100644 --- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java +++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java @@ -17,10 +17,16 @@ public class ArchitectureTest { public static final String NET_HOSTSHARING_HSADMINNG = "net.hostsharing.hsadminng"; @ArchTest + @SuppressWarnings("unused") public static final ArchRule doNotUseJUnit4 = noClasses() .should().accessClassesThat() .resideInAPackage("org.junit"); + @ArchTest + @SuppressWarnings("unused") + public static final ArchRule dontUseImplSuffix = noClasses() + .should().haveSimpleNameEndingWith("Impl"); + @ArchTest @SuppressWarnings("unused") public static final ArchRule contextPackageRule = classes() @@ -68,7 +74,7 @@ public class ArchitectureTest { public static final ArchRule HsOfficePartnerPackageRule = classes() .that().resideInAPackage("..hs.office.partner..") .should().onlyBeAccessed().byClassesThat() - .resideInAnyPackage("..hs.office.partner.."); + .resideInAnyPackage("..hs.office.partner..", "..hs.office.debitor.."); @ArchTest @SuppressWarnings("unused") diff --git a/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java b/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java index 6e4a8ebd..85f645d9 100644 --- a/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java @@ -4,9 +4,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaObjectRetrievalFailureException; import org.springframework.orm.jpa.JpaSystemException; import org.springframework.web.context.request.WebRequest; +import javax.persistence.EntityNotFoundException; + +import java.util.NoSuchElementException; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -44,6 +49,75 @@ class RestResponseEntityExceptionHandlerUnitTest { assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [401] First Line"); } + @Test + void handleJpaObjectRetrievalFailureExceptionWithDisplayName() { + // given + final var givenException = new JpaObjectRetrievalFailureException( + new EntityNotFoundException( + "Unable to find net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity with id 12345-123454") + ); + final var givenWebRequest = mock(WebRequest.class); + + // when + final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest); + + // then + assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("Unable to find Partner with uuid 12345-123454"); + } + + @Test + void handleJpaObjectRetrievalFailureExceptionIfEntityClassCannotBeDetermined() { + // given + final var givenException = new JpaObjectRetrievalFailureException( + new EntityNotFoundException( + "Unable to find net.hostsharing.hsadminng.WhateverEntity with id 12345-123454") + ); + final var givenWebRequest = mock(WebRequest.class); + + // when + final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest); + + // then + assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400); + assertThat(errorResponse.getBody().getMessage()).isEqualTo( + "Unable to find net.hostsharing.hsadminng.WhateverEntity with id 12345-123454"); + } + + @Test + void handleJpaObjectRetrievalFailureExceptionIfPatternDoesNotMatch() { + // given + final var givenException = new JpaObjectRetrievalFailureException( + new EntityNotFoundException("whatever error message") + ); + final var givenWebRequest = mock(WebRequest.class); + + // when + final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest); + + // then + assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("whatever error message"); + } + + @Test + void handleJpaObjectRetrievalFailureExceptionWithEntityName() { + // given + final var givenException = new JpaObjectRetrievalFailureException( + new EntityNotFoundException("Unable to find " + + NoDisplayNameEntity.class.getTypeName() + + " with id 12345-123454") + ); + final var givenWebRequest = mock(WebRequest.class); + + // when + final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest); + + // then + assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("Unable to find NoDisplayNameEntity with uuid 12345-123454"); + } + @Test void jpaExceptionWithUnknownErrorCode() { // given @@ -59,6 +133,20 @@ class RestResponseEntityExceptionHandlerUnitTest { assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [999] First Line"); } + @Test + void handleNoSuchElementException() { + // given + final var givenException = new NoSuchElementException("some error message"); + final var givenWebRequest = mock(WebRequest.class); + + // when + final var errorResponse = exceptionHandler.handleNoSuchElementException(givenException, givenWebRequest); + + // then + assertThat(errorResponse.getStatusCodeValue()).isEqualTo(404); + assertThat(errorResponse.getBody().getMessage()).isEqualTo("some error message"); + } + @Test void handleOtherExceptionsWithoutErrorCode() { // given @@ -87,4 +175,8 @@ class RestResponseEntityExceptionHandlerUnitTest { assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [418] First Line"); } + public static class NoDisplayNameEntity { + + } + } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java new file mode 100644 index 00000000..e699de81 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java @@ -0,0 +1,505 @@ +package net.hostsharing.hsadminng.hs.office.debitor; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.Accepts; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; +import net.hostsharing.test.JpaAttempt; +import org.json.JSONException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid; +import static net.hostsharing.test.JsonMatcher.lenientlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, JpaAttempt.class } +) +@Transactional +class HsOfficeDebitorControllerAcceptanceTest { + + private static int nextDebitorNumber = 20001; + + @LocalServerPort + private Integer port; + + @Autowired + Context context; + + @Autowired + Context contextMock; + + @Autowired + HsOfficeDebitorRepository debitorRepo; + + @Autowired + HsOfficePartnerRepository partnerRepo; + + @Autowired + HsOfficeContactRepository contactRepo; + + @Autowired + JpaAttempt jpaAttempt; + + Set tempDebitorUuids = new HashSet<>(); + + @Nested + @Accepts({ "Debitor:F(Find)" }) + class ListDebitors { + + @Test + void globalAdmin_withoutAssumedRoles_canViewAllDebitors_ifNoCriteriaGiven() throws JSONException { + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/debitors") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "debitorNumber": 10001, + "partner": { "person": { "personType": "LEGAL" } }, + "billingContact": { "label": "first contact" }, + "vatId": null, + "vatCountryCode": null, + "vatBusiness": true + }, + { + "debitorNumber": 10002, + "partner": { "person": { "tradeName": "Second e.K." } }, + "billingContact": { "label": "second contact" }, + "vatId": null, + "vatCountryCode": null, + "vatBusiness": true + }, + { + "debitorNumber": 10003, + "partner": { "person": { "tradeName": "Third OHG" } }, + "billingContact": { "label": "third contact" }, + "vatId": null, + "vatCountryCode": null, + "vatBusiness": true + } + ] + """)); + // @formatter:on + } + + @Test + void globalAdmin_withoutAssumedRoles_canFindDebitorDebitorByDebitorNumber() throws JSONException { + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/debitors?debitorNumber=10002") + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "debitorNumber": 10002, + "partner": { "person": { "tradeName": "Second e.K." } }, + "billingContact": { "label": "second contact" }, + "vatId": null, + "vatCountryCode": null, + "vatBusiness": true + } + ] + """)); + // @formatter:on + } + } + + @Nested + @Accepts({ "Debitor:C(Create)" }) + class AddDebitor { + + @Test + void globalAdmin_withoutAssumedRole_canAddDebitor() { + + context.define("superuser-alex@hostsharing.net"); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "partnerUuid": "%s", + "billingContactUuid": "%s", + "debitorNumber": "%s", + "vatId": "VAT123456", + "vatCountryCode": "DE", + "vatBusiness": true + } + """.formatted( givenPartner.getUuid(), givenContact.getUuid(), nextDebitorNumber++)) + .port(port) + .when() + .post("http://localhost/api/hs/office/debitors") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("vatId", is("VAT123456")) + .body("billingContact.label", is(givenContact.getLabel())) + .body("partner.person.tradeName", is(givenPartner.getPerson().getTradeName())) + .header("Location", startsWith("http://localhost")) + .extract().header("Location"); // @formatter:on + + // finally, the new debitor can be accessed under the generated UUID + final var newUserUuid = toCleanup(UUID.fromString( + location.substring(location.lastIndexOf('/') + 1))); + assertThat(newUserUuid).isNotNull(); + } + + @Test + void globalAdmin_canNotAddDebitor_ifContactDoesNotExist() { + + context.define("superuser-alex@hostsharing.net"); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0); + final var givenContactUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "partnerUuid": "%s", + "billingContactUuid": "%s", + "debitorNumber": "%s", + "vatId": "VAT123456", + "vatCountryCode": "DE", + "vatBusiness": true + } + """.formatted( givenPartner.getUuid(), givenContactUuid, nextDebitorNumber++)) + .port(port) + .when() + .post("http://localhost/api/hs/office/debitors") + .then().log().all().assertThat() + .statusCode(400) + .body("message", is("Unable to find Contact with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + // @formatter:on + } + + @Test + void globalAdmin_canNotAddDebitor_ifPartnerDoesNotExist() { + + context.define("superuser-alex@hostsharing.net"); + final var givenPartnerUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "partnerUuid": "%s", + "billingContactUuid": "%s", + "debitorNumber": "%s", + "vatId": "VAT123456", + "vatCountryCode": "DE", + "vatBusiness": true + } + """.formatted( givenPartnerUuid, givenContact.getUuid(), nextDebitorNumber++)) + .port(port) + .when() + .post("http://localhost/api/hs/office/debitors") + .then().log().all().assertThat() + .statusCode(400) + .body("message", is("Unable to find Partner with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6")); + // @formatter:on + } + } + + @Nested + @Accepts({ "Debitor:R(Read)" }) + class GetDebitor { + + @Test + void globalAdmin_withoutAssumedRole_canGetArbitraryDebitor() { + context.define("superuser-alex@hostsharing.net"); + final var givenDebitorUuid = debitorRepo.findDebitorByOptionalNameLike("First").get(0).getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/office/debitors/" + givenDebitorUuid) + .then().log().body().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "partner": { person: { "tradeName": "First GmbH" } }, + "billingContact": { "label": "first contact" } + } + """)); // @formatter:on + } + + @Test + @Accepts({ "Debitor:X(Access Control)" }) + void normalUser_canNotGetUnrelatedDebitor() { + context.define("superuser-alex@hostsharing.net"); + final var givenDebitorUuid = debitorRepo.findDebitorByOptionalNameLike("First").get(0).getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .get("http://localhost/api/hs/office/debitors/" + givenDebitorUuid) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + } + + @Test + @Accepts({ "Debitor:X(Access Control)" }) + void contactAdminUser_canGetRelatedDebitor() { + context.define("superuser-alex@hostsharing.net"); + final var givenDebitorUuid = debitorRepo.findDebitorByOptionalNameLike("first contact").get(0).getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "contact-admin@firstcontact.example.com") + .port(port) + .when() + .get("http://localhost/api/hs/office/debitors/" + givenDebitorUuid) + .then().log().body().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "partner": { person: { "tradeName": "First GmbH" } }, + "billingContact": { "label": "first contact" } + } + """)); // @formatter:on + } + } + + @Nested + @Accepts({ "Debitor:U(Update)" }) + class PatchDebitor { + + @Test + void globalAdmin_withoutAssumedRole_canPatchAllPropertiesOfArbitraryDebitor() { + + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = givenSomeTemporaryDebitor(); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "contactUuid": "%s", + "vatId": "VAT222222", + "vatCountryCode": "AA", + "vatBusiness": true + } + """.formatted(givenContact.getUuid())) + .port(port) + .when() + .patch("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid()) + .then().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("vatId", is("VAT222222")) + .body("vatCountryCode", is("AA")) + .body("vatBusiness", is(true)) + .body("billingContact.label", is(givenContact.getLabel())) + .body("partner.person.tradeName", is(givenDebitor.getPartner().getPerson().getTradeName())); + // @formatter:on + + // finally, the debitor is actually updated + context.define("superuser-alex@hostsharing.net"); + assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent().get() + .matches(partner -> { + assertThat(partner.getPartner().getPerson().getTradeName()).isEqualTo(givenDebitor.getPartner() + .getPerson() + .getTradeName()); + assertThat(partner.getBillingContact().getLabel()).isEqualTo("forth contact"); + assertThat(partner.getVatId()).isEqualTo("VAT222222"); + assertThat(partner.getVatCountryCode()).isEqualTo("AA"); + assertThat(partner.isVatBusiness()).isEqualTo(true); + return true; + }); + } + + @Test + void globalAdmin_withoutAssumedRole_canPatchPartialPropertiesOfArbitraryDebitor() { + + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = givenSomeTemporaryDebitor(); + final var newBillingContact = contactRepo.findContactByOptionalLabelLike("sixth").get(0); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "billingContactUuid": "%s", + "vatId": "VAT999999" + } + """.formatted(newBillingContact.getUuid())) + .port(port) + .when() + .patch("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid()) + .then().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("uuid", isUuidValid()) + .body("billingContact.label", is("sixth contact")) + .body("vatId", is("VAT999999")) + .body("vatCountryCode", is(givenDebitor.getVatCountryCode())) + .body("vatBusiness", is(givenDebitor.isVatBusiness())); + // @formatter:on + + // finally, the debitor is actually updated + assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent().get() + .matches(partner -> { + assertThat(partner.getPartner().getPerson().getTradeName()).isEqualTo(givenDebitor.getPartner() + .getPerson() + .getTradeName()); + assertThat(partner.getBillingContact().getLabel()).isEqualTo("sixth contact"); + assertThat(partner.getVatId()).isEqualTo("VAT999999"); + assertThat(partner.getVatCountryCode()).isEqualTo(givenDebitor.getVatCountryCode()); + assertThat(partner.isVatBusiness()).isEqualTo(givenDebitor.isVatBusiness()); + return true; + }); + } + + } + + @Nested + @Accepts({ "Debitor:D(Delete)" }) + class DeleteDebitor { + + @Test + void globalAdmin_withoutAssumedRole_canDeleteArbitraryDebitor() { + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = givenSomeTemporaryDebitor(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .delete("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid()) + .then().log().body().assertThat() + .statusCode(204); // @formatter:on + + // then the given debitor is gone + assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isEmpty(); + } + + @Test + @Accepts({ "Debitor:X(Access Control)" }) + void contactAdminUser_canNotDeleteRelatedDebitor() { + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = givenSomeTemporaryDebitor(); + assumeThat(givenDebitor.getBillingContact().getLabel()).isEqualTo("forth contact"); + + RestAssured // @formatter:off + .given() + .header("current-user", "contact-admin@forthcontact.example.com") + .port(port) + .when() + .delete("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid()) + .then().log().body().assertThat() + .statusCode(403); // @formatter:on + + // then the given debitor is still there + assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isNotEmpty(); + } + + @Test + @Accepts({ "Debitor:X(Access Control)" }) + void normalUser_canNotDeleteUnrelatedDebitor() { + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = givenSomeTemporaryDebitor(); + assumeThat(givenDebitor.getBillingContact().getLabel()).isEqualTo("forth contact"); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .delete("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid()) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + + // then the given debitor is still there + assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isNotEmpty(); + } + } + + private HsOfficeDebitorEntity givenSomeTemporaryDebitor() { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Fourth").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); + final var newDebitor = HsOfficeDebitorEntity.builder() + .uuid(UUID.randomUUID()) + .debitorNumber(nextDebitorNumber++) + .partner(givenPartner) + .billingContact(givenContact) + .build(); + + toCleanup(newDebitor.getUuid()); + + return debitorRepo.save(newDebitor); + }).assertSuccessful().returnedValue(); + } + + private UUID toCleanup(final UUID tempDebitorUuid) { + tempDebitorUuids.add(tempDebitorUuid); + return tempDebitorUuid; + } + + @AfterEach + void cleanup() { + tempDebitorUuids.forEach(uuid -> { + jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); + System.out.println("DELETING temporary debitor: " + uuid); + final var count = debitorRepo.deleteByUuid(uuid); + System.out.println("DELETED temporary debitor: " + uuid + (count > 0 ? " successful" : " failed")); + }); + }); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcherUnitTest.java new file mode 100644 index 00000000..ab250cc0 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityPatcherUnitTest.java @@ -0,0 +1,112 @@ +package net.hostsharing.hsadminng.hs.office.debitor; + +import net.hostsharing.hsadminng.PatchUnitTestBase; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorPatchResource; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.persistence.EntityManager; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; + +@TestInstance(PER_CLASS) +@ExtendWith(MockitoExtension.class) +class HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase< + HsOfficeDebitorPatchResource, + HsOfficeDebitorEntity + > { + + private static final UUID INITIAL_DEBITOR_UUID = UUID.randomUUID(); + private static final UUID INITIAL_PARTNER_UUID = UUID.randomUUID(); + private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID(); + private static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID(); + + private static final String PATCHED_VAT_COUNTRY_CODE = "ZZ"; + + private static final boolean PATCHED_VAT_BUSINESS = false; + + private final HsOfficePartnerEntity givenInitialPartner = HsOfficePartnerEntity.builder() + .uuid(INITIAL_PARTNER_UUID) + .build(); + + private final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder() + .uuid(INITIAL_CONTACT_UUID) + .build(); + @Mock + private EntityManager em; + + @BeforeEach + void initMocks() { + lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation -> + HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation -> + HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build()); + } + + @Override + protected HsOfficeDebitorEntity newInitialEntity() { + final var entity = new HsOfficeDebitorEntity(); + entity.setUuid(INITIAL_DEBITOR_UUID); + entity.setPartner(givenInitialPartner); + entity.setBillingContact(givenInitialContact); + entity.setVatId("initial VAT-ID"); + entity.setVatCountryCode("AA"); + entity.setVatBusiness(true); + return entity; + } + + @Override + protected HsOfficeDebitorPatchResource newPatchResource() { + return new HsOfficeDebitorPatchResource(); + } + + @Override + protected HsOfficeDebitorEntityPatcher createPatcher(final HsOfficeDebitorEntity debitor) { + return new HsOfficeDebitorEntityPatcher(em, debitor); + } + + @Override + protected Stream propertyTestDescriptors() { + return Stream.of( + new JsonNullableProperty<>( + "billingContact", + HsOfficeDebitorPatchResource::setBillingContactUuid, + PATCHED_CONTACT_UUID, + HsOfficeDebitorEntity::setBillingContact, + newBillingContact(PATCHED_CONTACT_UUID)) + .notNullable(), + new JsonNullableProperty<>( + "vatId", + HsOfficeDebitorPatchResource::setVatId, + "patched VAT-ID", + HsOfficeDebitorEntity::setVatId), + new JsonNullableProperty<>( + "vatCountryCode", + HsOfficeDebitorPatchResource::setVatCountryCode, + PATCHED_VAT_COUNTRY_CODE, + HsOfficeDebitorEntity::setVatCountryCode), + new JsonNullableProperty<>( + "vatBusiness", + HsOfficeDebitorPatchResource::setVatBusiness, + PATCHED_VAT_BUSINESS, + HsOfficeDebitorEntity::setVatBusiness) + .notNullable() + ); + } + + private HsOfficeContactEntity newBillingContact(final UUID uuid) { + final var newContact = new HsOfficeContactEntity(); + newContact.setUuid(uuid); + return newContact; + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityTest.java new file mode 100644 index 00000000..a65793a2 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorEntityTest.java @@ -0,0 +1,40 @@ +package net.hostsharing.hsadminng.hs.office.debitor; + +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsOfficeDebitorEntityTest { + + @Test + void toStringContainsPartnerAndContact() { + final var given = HsOfficeDebitorEntity.builder() + .debitorNumber(123456) + .partner(HsOfficePartnerEntity.builder() + .person(HsOfficePersonEntity.builder() + .tradeName("some trade name") + .build()) + .birthName("some birth name") + .build()) + .billingContact(HsOfficeContactEntity.builder().label("some label").build()) + .build(); + + final var result = given.toString(); + + assertThat(result).isEqualTo("debitor(123456: some trade name)"); + } + + @Test + void toShortStringContainsPartnerAndContact() { + final var given = HsOfficeDebitorEntity.builder() + .debitorNumber(123456) + .build(); + + final var result = given.toShortString(); + + assertThat(result).isEqualTo("123456"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java new file mode 100644 index 00000000..2e78f7d0 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java @@ -0,0 +1,464 @@ +package net.hostsharing.hsadminng.hs.office.debitor; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.context.ContextBasedTest; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity; +import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.test.Array; +import net.hostsharing.test.JpaAttempt; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.test.annotation.DirtiesContext; + +import javax.persistence.EntityManager; +import javax.servlet.http.HttpServletRequest; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf; +import static net.hostsharing.test.JpaAttempt.attempt; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ComponentScan(basePackageClasses = { HsOfficeDebitorRepository.class, Context.class, JpaAttempt.class }) +@DirtiesContext +class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest { + + @Autowired + HsOfficeDebitorRepository debitorRepo; + + @Autowired + HsOfficePartnerRepository partnerRepo; + + @Autowired + HsOfficeContactRepository contactRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @Autowired + EntityManager em; + + @Autowired + JpaAttempt jpaAttempt; + + @MockBean + HttpServletRequest request; + + Set tempDebitors = new HashSet<>(); + + @Nested + class CreateDebitor { + + @Test + public void testHostsharingAdmin_withoutAssumedRole_canCreateNewDebitor() { + // given + context("superuser-alex@hostsharing.net"); + final var count = debitorRepo.count(); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First GmbH").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("first contact").get(0); + + // when + final var result = attempt(em, () -> { + final var newDebitor = toCleanup(HsOfficeDebitorEntity.builder() + .uuid(UUID.randomUUID()) + .debitorNumber(20001) + .partner(rawReference(givenPartner)) + .billingContact(rawReference(givenContact)) + .build()); + return debitorRepo.save(newDebitor); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeDebitorEntity::getUuid).isNotNull(); + assertThatDebitorIsPersisted(result.returnedValue()); + assertThat(debitorRepo.count()).isEqualTo(count + 1); + } + + @Test + public void createsAndGrantsRoles() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll()); + + // when + attempt(em, () -> { + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Fourth").get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0); + final var newDebitor = toCleanup(HsOfficeDebitorEntity.builder() + .uuid(UUID.randomUUID()) + .debitorNumber(20002) + .partner(rawReference(givenPartner)) + .billingContact(rawReference(givenContact)) + .build()); + return debitorRepo.save(newDebitor); + }).assertSuccessful(); + + // then + assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from( + initialRoleNames, + "hs_office_debitor#20002Fourthe.G.-forthcontact.admin", + "hs_office_debitor#20002Fourthe.G.-forthcontact.owner", + "hs_office_debitor#20002Fourthe.G.-forthcontact.tenant")); + assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromSkippingNull( + initialGrantNames, + + "{ grant perm * on hs_office_debitor#20002Fourthe.G.-forthcontact to role hs_office_debitor#20002Fourthe.G.-forthcontact.owner by system and assume }", + "{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.owner to role global#global.admin by system and assume }", + + "{ grant perm edit on hs_office_debitor#20002Fourthe.G.-forthcontact to role hs_office_debitor#20002Fourthe.G.-forthcontact.admin by system and assume }", + "{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.admin to role hs_office_debitor#20002Fourthe.G.-forthcontact.owner by system and assume }", + + "{ grant perm view on hs_office_debitor#20002Fourthe.G.-forthcontact to role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant by system and assume }", + "{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant to role hs_office_contact#forthcontact.admin by system and assume }", + "{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant to role hs_office_debitor#20002Fourthe.G.-forthcontact.admin by system and assume }", + "{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant to role hs_office_partner#Fourthe.G.-forthcontact.admin by system and assume }", + "{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant to role hs_office_person#Fourthe.G..admin by system and assume }", + "{ grant role hs_office_partner#Fourthe.G.-forthcontact.tenant to role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant by system and assume }", + "{ grant role hs_office_contact#forthcontact.tenant to role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant by system and assume }", + "{ grant role hs_office_person#Fourthe.G..tenant to role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant by system and assume }", + + null)); + } + + private void assertThatDebitorIsPersisted(final HsOfficeDebitorEntity saved) { + final var found = debitorRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved); + } + } + + @Nested + class FindByOptionalName { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllDebitors() { + // given + context("superuser-alex@hostsharing.net"); + + // when + final var result = debitorRepo.findDebitorByOptionalNameLike(null); + + // then + allTheseDebitorsAreReturned( + result, + "debitor(10001: First GmbH)", + "debitor(10002: Second e.K.)", + "debitor(10003: Third OHG)"); + } + + @ParameterizedTest + @ValueSource(strings = { + "hs_office_partner#FirstGmbH-firstcontact.admin", + "hs_office_person#FirstGmbH.admin", + "hs_office_contact#firstcontact.admin", + }) + public void relatedPersonAdmin_canViewRelatedDebitors(final String assumedRole) { + // given: + context("superuser-alex@hostsharing.net", assumedRole); + + // when: + final var result = debitorRepo.findDebitorByOptionalNameLike(null); + + // then: + exactlyTheseDebitorsAreReturned(result, "debitor(10001: First GmbH)"); + } + + @Test + public void unrelatedUser_canNotViewAnyDebitor() { + // given: + context("selfregistered-test-user@hostsharing.org"); + + // when: + final var result = debitorRepo.findDebitorByOptionalNameLike(null); + + // then: + assertThat(result).isEmpty(); + } + } + + @Nested + class FindByDebitorNumberLike { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllDebitors() { + // given + context("superuser-alex@hostsharing.net"); + + // when + final var result = debitorRepo.findDebitorByDebitorNumber(10003); + + // then + exactlyTheseDebitorsAreReturned(result, "debitor(10003: Third OHG)"); + } + } + + @Nested + class FindByNameLike { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllDebitors() { + // given + context("superuser-alex@hostsharing.net"); + + // when + final var result = debitorRepo.findDebitorByOptionalNameLike("third contact"); + + // then + exactlyTheseDebitorsAreReturned(result, "debitor(10003: Third OHG)"); + } + } + + @Nested + class UpdateDebitor { + + @Test + public void hostsharingAdmin_withoutAssumedRole_canUpdateArbitraryDebitor() { + // given + context("superuser-alex@hostsharing.net"); + final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "fifth contact"); + assertThatDebitorIsVisibleForUserWithRole( + givenDebitor, + "hs_office_partner#Fourthe.G.-forthcontact.admin"); + assertThatDebitorActuallyInDatabase(givenDebitor); + final var givenNewContact = contactRepo.findContactByOptionalLabelLike("sixth contact").get(0); + final String givenNewVatId = "NEW-VAT-ID"; + final String givenNewVatCountryCode = "NC"; + final boolean givenNewVatBusiness = !givenDebitor.isVatBusiness(); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + givenDebitor.setBillingContact(rawReference(givenNewContact)); + givenDebitor.setVatId(givenNewVatId); + givenDebitor.setVatCountryCode(givenNewVatCountryCode); + givenDebitor.setVatBusiness(givenNewVatBusiness); + return toCleanup(debitorRepo.save(givenDebitor)); + }); + + // then + result.assertSuccessful(); + assertThatDebitorIsVisibleForUserWithRole( + result.returnedValue(), + "global#global.admin"); + assertThatDebitorIsVisibleForUserWithRole( + result.returnedValue(), + "hs_office_contact#sixthcontact.admin"); + assertThatDebitorIsNotVisibleForUserWithRole( + result.returnedValue(), + "hs_office_contact#fifthcontact.admin"); + } + + @Test + public void partnerAdmin_canNotUpdateRelatedDebitor() { + // given + context("superuser-alex@hostsharing.net"); + final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "eighth"); + assertThatDebitorIsVisibleForUserWithRole( + givenDebitor, + "hs_office_partner#Fourthe.G.-forthcontact.admin"); + assertThatDebitorActuallyInDatabase(givenDebitor); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_office_partner#Fourthe.G.-forthcontact.admin"); + givenDebitor.setVatId("NEW-VAT-ID"); + return debitorRepo.save(givenDebitor); + }); + + // then + result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + "[403] Subject ", " is not allowed to update hs_office_debitor uuid"); + } + + @Test + public void contactAdmin_canNotUpdateRelatedDebitor() { + // given + context("superuser-alex@hostsharing.net"); + final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "ninth"); + assertThatDebitorIsVisibleForUserWithRole( + givenDebitor, + "hs_office_contact#ninthcontact.admin"); + assertThatDebitorActuallyInDatabase(givenDebitor); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact.admin"); + givenDebitor.setVatId("NEW-VAT-ID"); + return debitorRepo.save(givenDebitor); + }); + + // then + result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + "[403] Subject ", " is not allowed to update hs_office_debitor uuid"); + } + + private void assertThatDebitorActuallyInDatabase(final HsOfficeDebitorEntity saved) { + final var found = debitorRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved); + } + + private void assertThatDebitorIsVisibleForUserWithRole( + final HsOfficeDebitorEntity entity, + final String assumedRoles) { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", assumedRoles); + assertThatDebitorActuallyInDatabase(entity); + }).assertSuccessful(); + } + + private void assertThatDebitorIsNotVisibleForUserWithRole( + final HsOfficeDebitorEntity entity, + final String assumedRoles) { + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net", assumedRoles); + final var found = debitorRepo.findByUuid(entity.getUuid()); + assertThat(found).isEmpty(); + }).assertSuccessful(); + } + } + + @Nested + class DeleteByUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canDeleteAnyDebitor() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "tenth"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + debitorRepo.deleteByUuid(givenDebitor.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-fran@hostsharing.net", null); + return debitorRepo.findByUuid(givenDebitor.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void relatedPerson_canNotDeleteTheirRelatedDebitor() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "eleventh"); + + // when + final var result = jpaAttempt.transacted(() -> { + context("person-Fourthe.G.@example.com"); + assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent(); + + debitorRepo.deleteByUuid(givenDebitor.getUuid()); + }); + + // then + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "[403] Subject ", " not allowed to delete hs_office_debitor"); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return debitorRepo.findByUuid(givenDebitor.getUuid()); + }).assertSuccessful().returnedValue()).isPresent(); // still there + } + + @Test + public void deletingADebitorAlsoDeletesRelatedRolesAndGrants() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll())); + final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "twelfth"); + assertThat(rawRoleRepo.findAll().size()).as("precondition failed: unexpected number of roles created") + .isEqualTo(initialRoleNames.length + 3); + assertThat(rawGrantRepo.findAll().size()).as("precondition failed: unexpected number of grants created") + .isEqualTo(initialGrantNames.length + 12); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return debitorRepo.deleteByUuid(givenDebitor.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isEqualTo(1); + assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + } + } + + private HsOfficePartnerEntity rawReference(final HsOfficePartnerEntity givenPartner) { + return em.getReference(HsOfficePartnerEntity.class, givenPartner.getUuid()); + } + + private HsOfficeContactEntity rawReference(final HsOfficeContactEntity givenContact) { + return em.getReference(HsOfficeContactEntity.class, givenContact.getUuid()); + } + + private HsOfficeDebitorEntity givenSomeTemporaryDebitor(final String partner, final String contact) { + return jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike(partner).get(0); + final var givenContact = contactRepo.findContactByOptionalLabelLike(contact).get(0); + final var newDebitor = HsOfficeDebitorEntity.builder() + .uuid(UUID.randomUUID()) + .debitorNumber(20000) + .partner(rawReference(givenPartner)) + .billingContact(rawReference(givenContact)) + .build(); + + toCleanup(newDebitor); + + return debitorRepo.save(newDebitor); + }).assertSuccessful().returnedValue(); + } + + private HsOfficeDebitorEntity toCleanup(final HsOfficeDebitorEntity tempDebitor) { + tempDebitors.add(tempDebitor); + return tempDebitor; + } + + @AfterEach + void cleanup() { + context("superuser-alex@hostsharing.net", null); + tempDebitors.forEach(tempDebitor -> { + System.out.println("DELETING temporary debitor: " + tempDebitor.toString()); + debitorRepo.deleteByUuid(tempDebitor.getUuid()); + }); + } + + void exactlyTheseDebitorsAreReturned(final List actualResult, final String... debitorNames) { + assertThat(actualResult) + .extracting(HsOfficeDebitorEntity::toString) + .containsExactlyInAnyOrder(debitorNames); + } + + void allTheseDebitorsAreReturned(final List actualResult, final String... debitorNames) { + assertThat(actualResult) + .extracting(HsOfficeDebitorEntity::toString) + .contains(debitorNames); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java index d4355ab9..d494b960 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java @@ -87,6 +87,10 @@ class HsOfficePartnerControllerAcceptanceTest { { "person": { "tradeName": "Second e.K." }, "contact": { "label": "second contact" } + }, + { + "person": { "personType": "SOLE_REPRESENTATION" }, + "contact": { "label": "forth contact" } } ] """)); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java index dd85d8fa..9bd09a7e 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java @@ -70,6 +70,30 @@ class HsOfficePersonControllerAcceptanceTest { .body("", lenientlyEquals(""" [ { + "personType": "LEGAL", + "tradeName": "First GmbH", + "givenName": null, + "familyName": null + }, + { + "personType": "LEGAL", + "tradeName": "Second e.K.", + "givenName": "Miller", + "familyName": "Sandra" + }, + { + "personType": "SOLE_REPRESENTATION", + "tradeName": "Third OHG", + "givenName": null, + "familyName": null + }, + { + "personType": "SOLE_REPRESENTATION", + "tradeName": "Fourth e.G.", + "givenName": null, + "familyName": null + }, + { "personType": "NATURAL", "tradeName": null, "givenName": "Anita", @@ -81,24 +105,6 @@ class HsOfficePersonControllerAcceptanceTest { "givenName": "Bessler", "familyName": "Mel" }, - { - "personType": "LEGAL", - "tradeName": "First GmbH", - "givenName": null, - "familyName": null - }, - { - "personType": "SOLE_REPRESENTATION", - "tradeName": "Third OHG", - "givenName": null, - "familyName": null - }, - { - "personType": "LEGAL", - "tradeName": "Second e.K.", - "givenName": "Miller", - "familyName": "Sandra" - }, { "personType": "NATURAL", "tradeName": null, @@ -406,7 +412,7 @@ class HsOfficePersonControllerAcceptanceTest { final var entity = personRepo.findByUuid(uuid); final var count = personRepo.deleteByUuid(uuid); System.out.println("DELETED temporary person: " + uuid + (count > 0 ? " successful" : " failed") + - (" (" + entity.map(HsOfficePersonEntity::getDisplayName).orElse("null") + ")")); + (" (" + entity.map(hsOfficePersonEntity -> hsOfficePersonEntity.toShortString()).orElse("null") + ")")); }).assertSuccessful(); }); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java index fc7392b6..5b7ca93d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java @@ -14,7 +14,7 @@ class HsOfficePersonEntityUnitTest { .tradeName("some trade name") .build(); - final var actualDisplay = givenPersonEntity.getDisplayName(); + final var actualDisplay = givenPersonEntity.toShortString(); assertThat(actualDisplay).isEqualTo("some trade name"); } @@ -26,7 +26,7 @@ class HsOfficePersonEntityUnitTest { .givenName("some given name") .build(); - final var actualDisplay = givenPersonEntity.getDisplayName(); + final var actualDisplay = givenPersonEntity.toShortString(); assertThat(actualDisplay).isEqualTo("some family name, some given name"); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java index 50a3c10b..588bd13d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java @@ -3,7 +3,9 @@ package net.hostsharing.hsadminng.hs.office.person; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleRepository; import net.hostsharing.test.Array; import net.hostsharing.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; @@ -266,7 +268,7 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { context("superuser-alex@hostsharing.net", null); final var result = personRepo.findPersonByOptionalNameLike("some temporary person"); result.forEach(tempPerson -> { - System.out.println("DELETING temporary person: " + tempPerson.getDisplayName()); + System.out.println("DELETING temporary person: " + tempPerson.toShortString()); personRepo.deleteByUuid(tempPerson.getUuid()); }); } @@ -293,7 +295,7 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest { void allThesePersonsAreReturned(final List actualResult, final String... personLabels) { assertThat(actualResult) - .extracting(HsOfficePersonEntity::getDisplayName) + .extracting(hsOfficePersonEntity -> hsOfficePersonEntity.toShortString()) .contains(personLabels); } }