From 0e4602aac66b13c33bcb0f2f930c83c3f217316f Mon Sep 17 00:00:00 2001
From: Michael Hoennig <michael@hoennig.de>
Date: Tue, 9 Aug 2022 17:51:50 +0200
Subject: [PATCH] add updatePackage (description) using JsonNullableModule and
 HTTP-to-DB test with RestAssured

---
 build.gradle                                  |  14 +-
 .../hsadminng/HsadminNgApplication.java       |   1 -
 .../config/JsonObjectMapperConfiguration.java |  18 ++
 .../hs/hspackage/PackageController.java       |  26 +++
 .../hsadminng/hs/hspackage/PackageEntity.java |   8 +-
 .../hs/hspackage/PackageRepository.java       |   4 +
 .../rbac/rbacuser/RbacUserController.java     |   5 -
 src/main/resources/api-definition.yaml        |  48 ++++-
 src/main/resources/api-mappings.yaml          |   3 +
 .../2022-07-29-070-hs-package-rbac.sql        |   3 +-
 .../2022-07-29-070-hs-package-test-data.sql   |   4 +-
 .../changelog/2022-07-29-070-hs-package.sql   |   5 +-
 .../PackageControllerAcceptanceTest.java      | 192 ++++++++++++++++++
 .../hspackage/PackageControllerRestTest.java  | 162 +++++++++++----
 .../hsadminng/hs/hspackage/TestPackage.java   |   2 +-
 15 files changed, 437 insertions(+), 58 deletions(-)
 create mode 100644 src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageControllerAcceptanceTest.java

diff --git a/build.gradle b/build.gradle
index 82667b4f..194e415d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,7 @@
 plugins {
     id 'java'
     id 'org.springframework.boot' version '2.7.2'
-    id 'io.openapiprocessor.openapi-processor' version '2021.3'
+    id 'io.openapiprocessor.openapi-processor' version '2022.2'
     id 'io.spring.dependency-management' version '1.0.12.RELEASE'
     id 'com.github.jk1.dependency-license-report' version '2.1'
     id "org.owasp.dependencycheck" version "7.1.1"
@@ -46,7 +46,7 @@ dependencies {
     implementation 'org.springdoc:springdoc-openapi-ui:1.6.9'
     implementation 'org.liquibase:liquibase-core'
     implementation 'com.vladmihalcea:hibernate-types-55:2.17.1'
-    implementation 'org.openapitools:jackson-databind-nullable:0.2.3'// https://mvnrepository.com/artifact/org.modelmapper/modelmapper
+    implementation 'org.openapitools:jackson-databind-nullable:0.2.3'
     implementation 'org.modelmapper:modelmapper:3.1.0'
 
     compileOnly 'org.projectlombok:lombok'
@@ -62,6 +62,7 @@ dependencies {
     testImplementation 'org.testcontainers:junit-jupiter'
     testImplementation 'org.testcontainers:postgresql'
     testImplementation 'com.tngtech.archunit:archunit-junit5:1.0.0-rc1'
+    testImplementation 'io.rest-assured:spring-mock-mvc'
 }
 
 dependencyManagement {
@@ -80,11 +81,12 @@ tasks.named('test') {
 
 openapiProcessor {
     spring {
-        processor 'io.openapiprocessor:openapi-processor-spring:2021.4'
+        processor 'io.openapiprocessor:openapi-processor-spring:2022.4'
         apiPath "$projectDir/src/main/resources/api-definition.yaml"
         targetDir "$projectDir/build/generated/sources/openapi"
         mapping "$projectDir/src/main/resources/api-mappings.yaml"
         showWarnings true
+        openApiNullable true
     }
 }
 sourceSets.main.java.srcDir 'build/generated/sources/openapi'
@@ -92,12 +94,12 @@ compileJava.dependsOn('processSpring')
 
 spotless {
     java {
-        removeUnusedImports()
+        // removeUnusedImports() TODO: reactivate once it can deal with multi-line-strings
+        indentWithSpaces(4)
         endWithNewline()
         toggleOffOn()
 
-        // target 'src/main/java**/*.java', 'src/test/java**/*.java' // not generated
-        target project.fileTree(project.rootDir) {
+        target fileTree(rootDir) {
             include '**/*.java'
             exclude '**/generated/**/*.java'
         }
diff --git a/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java b/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java
index 20764d4e..af29526b 100644
--- a/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java
+++ b/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java
@@ -9,5 +9,4 @@ public class HsadminNgApplication {
     public static void main(String[] args) {
         SpringApplication.run(HsadminNgApplication.class, args);
     }
-
 }
diff --git a/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java b/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java
new file mode 100644
index 00000000..dcd6af6e
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/config/JsonObjectMapperConfiguration.java
@@ -0,0 +1,18 @@
+package net.hostsharing.hsadminng.config;
+
+import org.openapitools.jackson.nullable.JsonNullableModule;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+
+@Configuration
+public class JsonObjectMapperConfiguration {
+
+    @Bean
+    @Primary
+    public Jackson2ObjectMapperBuilder customObjectMapper() {
+        return new Jackson2ObjectMapperBuilder()
+            .modules(new JsonNullableModule());
+    }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageController.java b/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageController.java
index eb188b21..527923a2 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageController.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageController.java
@@ -3,13 +3,16 @@ package net.hostsharing.hsadminng.hs.hspackage;
 import net.hostsharing.hsadminng.context.Context;
 import net.hostsharing.hsadminng.generated.api.v1.api.PackagesApi;
 import net.hostsharing.hsadminng.generated.api.v1.model.PackageResource;
+import net.hostsharing.hsadminng.generated.api.v1.model.PackageUpdateResource;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.transaction.Transactional;
 import java.util.List;
+import java.util.UUID;
 
+import static net.hostsharing.hsadminng.Mapper.map;
 import static net.hostsharing.hsadminng.Mapper.mapList;
 
 @RestController
@@ -36,4 +39,27 @@ public class PackageController implements PackagesApi {
         return ResponseEntity.ok(mapList(result, PackageResource.class));
     }
 
+    @Override
+    @Transactional
+    public ResponseEntity<PackageResource> updatePackage(
+        final String currentUser,
+        final String assumedRoles,
+        final UUID packageUuid,
+        final PackageUpdateResource body) {
+
+        context.setCurrentUser(currentUser);
+        if (assumedRoles != null && !assumedRoles.isBlank()) {
+            context.assumeRoles(assumedRoles);
+        }
+        final var current = packageRepository.findByUuid(packageUuid);
+        if (body.getDescription() != null) {
+            body.getDescription().ifPresent(current::setDescription);
+        } else {
+            body.toString();
+        }
+        final var saved = packageRepository.save(current);
+        final var mapped = map(saved, PackageResource.class);
+        return ResponseEntity.ok(mapped);
+    }
+
 }
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageEntity.java
index 18117473..ff87a851 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageEntity.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageEntity.java
@@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.hspackage;
 import lombok.AllArgsConstructor;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
+import lombok.Setter;
 import net.hostsharing.hsadminng.hs.hscustomer.CustomerEntity;
 
 import javax.persistence.*;
@@ -11,15 +12,18 @@ import java.util.UUID;
 @Entity
 @Table(name = "package_rv")
 @Getter
+@Setter
 @NoArgsConstructor
 @AllArgsConstructor
 public class PackageEntity {
 
     private @Id UUID uuid;
 
-    private String name;
-
     @ManyToOne(optional = false)
     @JoinColumn(name = "customeruuid")
     private CustomerEntity customer;
+
+    private String name;
+
+    private String description;
 }
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepository.java
index bafe0882..5059412b 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepository.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hspackage/PackageRepository.java
@@ -10,4 +10,8 @@ public interface PackageRepository extends Repository<PackageEntity, UUID> {
 
     @Query("SELECT p FROM PackageEntity p WHERE :name is null or p.name like concat(:name, '%')")
     List<PackageEntity> findAllByOptionalNameLike(final String name);
+
+    PackageEntity findByUuid(UUID packageUuid);
+
+    PackageEntity save(PackageEntity current);
 }
diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserController.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserController.java
index 46e02a86..b05ef93f 100644
--- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserController.java
+++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserController.java
@@ -1,10 +1,5 @@
 package net.hostsharing.hsadminng.rbac.rbacuser;
 
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.media.ArraySchema;
-import io.swagger.v3.oas.annotations.media.Content;
-import io.swagger.v3.oas.annotations.media.Schema;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import net.hostsharing.hsadminng.context.Context;
 import net.hostsharing.hsadminng.generated.api.v1.api.RbacusersApi;
 import net.hostsharing.hsadminng.generated.api.v1.model.RbacUserPermissionResource;
diff --git a/src/main/resources/api-definition.yaml b/src/main/resources/api-definition.yaml
index 695c090c..09c2ffe8 100644
--- a/src/main/resources/api-definition.yaml
+++ b/src/main/resources/api-definition.yaml
@@ -182,6 +182,40 @@ paths:
                                 type: array
                                 items:
                                     $ref: '#/components/schemas/Package'
+                "401":
+                    $ref: '#/components/responses/Unauthorized'
+                "403":
+                    $ref: '#/components/responses/Forbidden'
+    /api/packages/{packageUUID}:
+        patch:
+            tags:
+                - packages
+            operationId: updatePackage
+            parameters:
+                -   $ref: '#/components/parameters/currentUser'
+                -   $ref: '#/components/parameters/assumedRoles'
+                -   name: packageUUID
+                    in: path
+                    required: true
+                    schema:
+                        type: string
+                        format: uuid
+            requestBody:
+                content:
+                    'application/json':
+                        schema:
+                            $ref: '#/components/schemas/PackageUpdate'
+            responses:
+                "200":
+                    description: OK
+                    content:
+                        'application/json':
+                            schema:
+                                $ref: '#/components/schemas/Package'
+                "401":
+                    $ref: '#/components/responses/Unauthorized'
+                "403":
+                    $ref: '#/components/responses/Forbidden'
 
 components:
 
@@ -290,10 +324,20 @@ components:
                 uuid:
                     type: string
                     format: uuid
-                name:
-                    type: string
                 customer:
                     $ref: '#/components/schemas/Customer'
+                name:
+                    type: string
+                description:
+                    type: string
+                    maxLength: 80
+        PackageUpdate:
+            type: object
+            properties:
+                description:
+                    type: string
+                    maxLength: 80
+                    nullable: true
         Error:
             type: object
             properties:
diff --git a/src/main/resources/api-mappings.yaml b/src/main/resources/api-mappings.yaml
index 0172154a..260edebf 100644
--- a/src/main/resources/api-mappings.yaml
+++ b/src/main/resources/api-mappings.yaml
@@ -12,3 +12,6 @@ map:
         - type: array => java.util.List
         - type: string:uuid => java.util.UUID
 
+    paths:
+        /api/packages/{packageUUID}:
+            null: org.openapitools.jackson.nullable.JsonNullable
diff --git a/src/main/resources/db/changelog/2022-07-29-070-hs-package-rbac.sql b/src/main/resources/db/changelog/2022-07-29-070-hs-package-rbac.sql
index 6e2c0eeb..689f6c19 100644
--- a/src/main/resources/db/changelog/2022-07-29-070-hs-package-rbac.sql
+++ b/src/main/resources/db/changelog/2022-07-29-070-hs-package-rbac.sql
@@ -188,6 +188,7 @@ drop view if exists package_rv;
 create or replace view package_rv as
 select target.*
     from package as target
-    where target.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('view', 'package', currentSubjectIds()));
+    where target.uuid in (select queryAccessibleObjectUuidsOfSubjectIds('view', 'package', currentSubjectIds()))
+    order by target.name;
 grant all privileges on package_rv to restricted;
 --//
diff --git a/src/main/resources/db/changelog/2022-07-29-070-hs-package-test-data.sql b/src/main/resources/db/changelog/2022-07-29-070-hs-package-test-data.sql
index 36877bd9..f2374930 100644
--- a/src/main/resources/db/changelog/2022-07-29-070-hs-package-test-data.sql
+++ b/src/main/resources/db/changelog/2022-07-29-070-hs-package-test-data.sql
@@ -37,8 +37,8 @@ create or replace procedure createPackageTestData(
                         set local hsadminng.currentTask to currentTask;
 
                         insert
-                            into package (name, customerUuid)
-                            values (pacName, cust.uuid)
+                            into package (customerUuid, name, description)
+                            values (cust.uuid, pacName, 'Here can add your own description of package ' || pacName || '.')
                             returning * into pac;
 
                         call grantRoleToUser(
diff --git a/src/main/resources/db/changelog/2022-07-29-070-hs-package.sql b/src/main/resources/db/changelog/2022-07-29-070-hs-package.sql
index 95b925d5..5f7ba39e 100644
--- a/src/main/resources/db/changelog/2022-07-29-070-hs-package.sql
+++ b/src/main/resources/db/changelog/2022-07-29-070-hs-package.sql
@@ -7,7 +7,8 @@
 create table if not exists package
 (
     uuid         uuid unique references RbacObject (uuid),
-    name         character varying(5),
-    customerUuid uuid references customer (uuid)
+    customerUuid uuid references customer (uuid),
+    name         varchar(5),
+    description  varchar(80)
 );
 --//
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageControllerAcceptanceTest.java
new file mode 100644
index 00000000..7b45e791
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageControllerAcceptanceTest.java
@@ -0,0 +1,192 @@
+package net.hostsharing.hsadminng.hs.hspackage;
+
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import net.hostsharing.hsadminng.HsadminNgApplication;
+import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Import;
+
+import javax.transaction.Transactional;
+import java.util.UUID;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assumptions.assumeThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+@SpringBootTest(
+    webEnvironment = WebEnvironment.RANDOM_PORT,
+    classes = HsadminNgApplication.class
+)
+// classes = { PackageController.class, JsonObjectMapperConfiguration.class },
+@Import(JsonObjectMapperConfiguration.class)
+@Transactional
+class PackageControllerAcceptanceTest {
+
+    @LocalServerPort
+    private Integer port;
+
+    @Nested
+    class ListPackages {
+
+        @Test
+        void withoutNameParameter() throws Exception {
+            // @formatter:off
+            RestAssured
+                .given()
+                    .header("current-user", "mike@hostsharing.net")
+                    .header("assumed-roles", "customer#aaa.admin")
+                .when()
+                    .get("http://localhost:" + port + "/api/packages")
+                .then().assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("[0].name", is("aaa00"))
+                    .body("[0].customer.reference", is(10000))
+                    .body("[1].name", is("aaa01"))
+                    .body("[1].customer.reference", is(10000))
+                    .body("[2].name", is("aaa02"))
+                    .body("[2].customer.reference", is(10000));
+            // @formatter:on
+        }
+
+        @Test
+        void withNameParameter() throws Exception {
+            // @formatter:off
+            RestAssured
+                .given()
+                    .header("current-user", "mike@hostsharing.net")
+                    .header("assumed-roles", "customer#aaa.admin")
+                .when()
+                    .get("http://localhost:" + port + "/api/packages?name=aaa01")
+                .then().assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("[0].name", is("aaa01"))
+                    .body("[0].customer.reference", is(10000));
+            // @formatter:on
+        }
+    }
+
+    @Nested
+    class UpdatePackage {
+
+        @Test
+        void withDescriptionUpdatesDescription() throws Exception {
+
+            assumeThat(getDescriptionOfPackage("aaa00"))
+                .isEqualTo("Here can add your own description of package aaa00.");
+
+            final var randomDescription = RandomStringUtils.randomAlphanumeric(80);
+
+            // @formatter:off
+            RestAssured
+                .given()
+                    .header("current-user", "mike@hostsharing.net")
+                    .header("assumed-roles", "customer#aaa.admin")
+                    .contentType(ContentType.JSON)
+                    .body(format("""
+                            {
+                                "description": "%s"
+                            }
+                          """, randomDescription))
+                .when()
+                    .patch("http://localhost:" + port + "/api/packages/" + getUuidOfPackage("aaa00"))
+                .then()
+                    .assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("name", is("aaa00"))
+                    .body("description", is(randomDescription));
+            // @formatter:on
+
+        }
+
+        @Test
+        void withNullDescriptionUpdatesDescriptionToNull() throws Exception {
+
+            assumeThat(getDescriptionOfPackage("aaa01"))
+                .isEqualTo("Here can add your own description of package aaa01.");
+
+            // @formatter:off
+            RestAssured
+                .given()
+                .header("current-user", "mike@hostsharing.net")
+                .header("assumed-roles", "customer#aaa.admin")
+                .contentType(ContentType.JSON)
+                .body("""
+                        {
+                            "description": null
+                        }
+                      """)
+                .when()
+                .patch("http://localhost:" + port + "/api/packages/" + getUuidOfPackage("aaa01"))
+                .then()
+                .assertThat()
+                .statusCode(200)
+                .contentType("application/json")
+                .body("name", is("aaa01"))
+                .body("description", equalTo(null));
+            // @formatter:on
+        }
+
+        @Test
+        void withoutDescriptionDoesNothing() throws Exception {
+
+            assumeThat(getDescriptionOfPackage("aaa02"))
+                .isEqualTo("Here can add your own description of package aaa02.");
+
+            // @formatter:off
+            RestAssured
+                .given()
+                    .header("current-user", "mike@hostsharing.net")
+                    .header("assumed-roles", "customer#aaa.admin")
+                    .contentType(ContentType.JSON)
+                    .body("{}")
+                .when()
+                    .patch("http://localhost:" + port + "/api/packages/" + getUuidOfPackage("aaa02"))
+                .then().assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("name", is("aaa02"))
+                    .body("description", is("Here can add your own description of package aaa02.")); // unchanged
+            // @formatter:on
+        }
+    }
+
+    UUID getUuidOfPackage(final String packageName) {
+        // @formatter:off
+        return UUID.fromString(RestAssured
+            .given()
+                .header("current-user", "mike@hostsharing.net")
+                .header("assumed-roles", "customer#aaa.admin")
+            .when()
+                .get("http://localhost:" + port + "/api/packages?name=" + packageName)
+            .then()
+                .statusCode(200)
+                .contentType("application/json")
+                .extract().path("[0].uuid"));
+        // @formatter:om
+    }
+
+    String getDescriptionOfPackage(final String packageName) {
+        // @formatter:off
+        return RestAssured
+            .given()
+            .header("current-user", "mike@hostsharing.net")
+            .header("assumed-roles", "customer#aaa.admin")
+            .when()
+            .get("http://localhost:" + port + "/api/packages?name=" + packageName)
+            .then()
+            .statusCode(200)
+            .contentType("application/json")
+            .extract().path("[0].description");
+        // @formatter:om
+    }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageControllerRestTest.java
index 92ebd1ef..e9b1008b 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageControllerRestTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/PackageControllerRestTest.java
@@ -1,11 +1,14 @@
 package net.hostsharing.hsadminng.hs.hspackage;
 
+import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
 import net.hostsharing.hsadminng.context.Context;
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
 import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.http.MediaType;
+import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
 
@@ -13,12 +16,15 @@ import java.util.List;
 
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.is;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
 @WebMvcTest(PackageController.class)
+@ContextConfiguration(classes = { PackageController.class, JsonObjectMapperConfiguration.class })
 class PackageControllerRestTest {
 
     @Autowired
@@ -28,51 +34,135 @@ class PackageControllerRestTest {
     @MockBean
     PackageRepository packageRepositoryMock;
 
-    @Test
-    void listPackagesWithoutNameParameter() throws Exception {
+    //    @Autowired
+    //    ObjectMapper objectMapper;
+    //
+    //    @Autowired
+    //    private Jackson2ObjectMapperBuilder jacksonObjectMapper;
+    //
+    //    @Autowired
+    //    private PackageController restController;
 
-        // given
-        final var givenPacs = List.of(TestPackage.xxx00, TestPackage.xxx01, TestPackage.xxx02);
-        when(packageRepositoryMock.findAllByOptionalNameLike(null)).thenReturn(givenPacs);
+    //    @Before
+    //    public void init(){
+    //
+    //        objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
+    //        objectMapper.registerModule(new JsonNullableModule());
+    //    }
 
-        // when
-        final var pacs = mockMvc.perform(MockMvcRequestBuilders
-                .get("/api/packages")
-                .header("current-user", "mike@hostsharing.net")
-                .header("assumed-roles", "customer#xxx.admin")
-                .accept(MediaType.APPLICATION_JSON))
+    @Nested
+    class ListPackages {
 
-            // then
-            .andExpect(status().isOk())
-            .andExpect(jsonPath("$", hasSize(3)))
-            .andExpect(jsonPath("$[0].name", is("xxx00")))
-            .andExpect(jsonPath("$[1].uuid", is(TestPackage.xxx01.getUuid().toString())))
-            .andExpect(jsonPath("$[2].customer.prefix", is("xxx")));
+        @Test
+        void withoutNameParameter() throws Exception {
 
-        verify(contextMock).setCurrentUser("mike@hostsharing.net");
-        verify(contextMock).assumeRoles("customer#xxx.admin");
+            // given
+            final var givenPacs = List.of(TestPackage.xxx00, TestPackage.xxx01, TestPackage.xxx02);
+            when(packageRepositoryMock.findAllByOptionalNameLike(null)).thenReturn(givenPacs);
+
+            // when
+            final var pacs = mockMvc.perform(MockMvcRequestBuilders
+                    .get("/api/packages")
+                    .header("current-user", "mike@hostsharing.net")
+                    .header("assumed-roles", "customer#xxx.admin")
+                    .accept(MediaType.APPLICATION_JSON))
+
+                // then
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$", hasSize(3)))
+                .andExpect(jsonPath("$[0].name", is("xxx00")))
+                .andExpect(jsonPath("$[1].uuid", is(TestPackage.xxx01.getUuid().toString())))
+                .andExpect(jsonPath("$[2].customer.prefix", is("xxx")));
+
+            verify(contextMock).setCurrentUser("mike@hostsharing.net");
+            verify(contextMock).assumeRoles("customer#xxx.admin");
+        }
+
+        @Test
+        void withNameParameter() throws Exception {
+
+            // given
+            final var givenPacs = List.of(TestPackage.xxx01);
+            when(packageRepositoryMock.findAllByOptionalNameLike("xxx01")).thenReturn(givenPacs);
+
+            // when
+            final var pacs = mockMvc.perform(MockMvcRequestBuilders
+                    .get("/api/packages?name=xxx01")
+                    .header("current-user", "mike@hostsharing.net")
+                    .header("assumed-roles", "customer#xxx.admin")
+                    .accept(MediaType.APPLICATION_JSON))
+
+                // then
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$", hasSize(1)))
+                .andExpect(jsonPath("$[0].name", is("xxx01")));
+
+            verify(contextMock).setCurrentUser("mike@hostsharing.net");
+            verify(contextMock).assumeRoles("customer#xxx.admin");
+        }
     }
 
-    @Test
-    void listPackagesWithNameParameter() throws Exception {
+    @Nested
+    class updatePackage {
 
-        // given
-        final var givenPacs = List.of(TestPackage.xxx01);
-        when(packageRepositoryMock.findAllByOptionalNameLike("xxx01")).thenReturn(givenPacs);
+        @Test
+        void withDescriptionUpdatesDescription() throws Exception {
 
-        // when
-        final var pacs = mockMvc.perform(MockMvcRequestBuilders
-                .get("/api/packages?name=xxx01")
-                .header("current-user", "mike@hostsharing.net")
-                .header("assumed-roles", "customer#xxx.admin")
-                .accept(MediaType.APPLICATION_JSON))
+            // given
+            final var givenPac = TestPackage.xxx01;
+            when(packageRepositoryMock.findByUuid(givenPac.getUuid())).thenReturn(givenPac);
+            when(packageRepositoryMock.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
 
-            // then
-            .andExpect(status().isOk())
-            .andExpect(jsonPath("$", hasSize(1)))
-            .andExpect(jsonPath("$[0].name", is("xxx01")));
+            // when
+            final var pacs = mockMvc.perform(MockMvcRequestBuilders
+                    .patch("/api/packages/" + givenPac.getUuid().toString())
+                    .header("current-user", "mike@hostsharing.net")
+                    .header("assumed-roles", "customer#xxx.admin")
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .content("""
+                        {
+                           "description": "some description"
+                        }
+                        """)
+                    .accept(MediaType.APPLICATION_JSON))
 
-        verify(contextMock).setCurrentUser("mike@hostsharing.net");
-        verify(contextMock).assumeRoles("customer#xxx.admin");
+                // then
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("description", is("some description")));
+
+            verify(contextMock).setCurrentUser("mike@hostsharing.net");
+            verify(contextMock).assumeRoles("customer#xxx.admin");
+            verify(packageRepositoryMock).save(argThat(entity ->
+                entity.getDescription().equals("some description") &&
+                    entity.getUuid().equals(givenPac.getUuid())));
+        }
+
+        @Test
+        void withoutDescriptionDoesNothing() throws Exception {
+
+            // given
+            final var givenPac = TestPackage.xxx01;
+            when(packageRepositoryMock.findByUuid(givenPac.getUuid())).thenReturn(givenPac);
+            when(packageRepositoryMock.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
+
+            // when
+            final var pacs = mockMvc.perform(MockMvcRequestBuilders
+                    .patch("/api/packages/" + givenPac.getUuid().toString())
+                    .header("current-user", "mike@hostsharing.net")
+                    .header("assumed-roles", "customer#xxx.admin")
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .content("{}")
+                    .accept(MediaType.APPLICATION_JSON))
+
+                // then
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("description", is(givenPac.getDescription())));
+
+            verify(contextMock).setCurrentUser("mike@hostsharing.net");
+            verify(contextMock).assumeRoles("customer#xxx.admin");
+            verify(packageRepositoryMock).save(argThat(entity ->
+                entity.getDescription() == givenPac.getDescription() &&
+                    entity.getUuid().equals(givenPac.getUuid())));
+        }
     }
 }
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/TestPackage.java b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/TestPackage.java
index f2fde56f..0444a836 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hspackage/TestPackage.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hspackage/TestPackage.java
@@ -12,6 +12,6 @@ public class TestPackage {
     public static final PackageEntity xxx02 = hsPackage(TestCustomer.xxx, "xxx02");
 
     public static PackageEntity hsPackage(final CustomerEntity customer, final String name) {
-        return new PackageEntity(randomUUID(), name, customer);
+        return new PackageEntity(randomUUID(), customer, name, "initial description of package " + name);
     }
 }