diff --git a/doc/test-concept.md b/doc/test-concept.md
index 690d1558..c1db1f29 100644
--- a/doc/test-concept.md
+++ b/doc/test-concept.md
@@ -90,6 +90,20 @@ Acceptance-tests, are blackbox-tests and do not count into test-code-cove
TODO.test: Complete the Acceptance-Tests test concept.
+#### Scenario-Tests
+
+Our Scenario-tests are induced by business use-cases.
+They test from the REST API all the way down to the database.
+
+Most scenario-tests are positive tests, they test if business scenarios do work.
+But few might be negative tests, which test if specific forbidden data gets rejected.
+
+Our scenario tests also generate test-reports which contain the REST-API calls needed for each scenario.
+These reports can be used as examples for the API usage from a business perspective.
+
+There is an extra document regarding scenario-test, see [Scenario-Tests README](../src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/README.md).
+
+
#### Performance-Tests
Performance-critical scenarios have to be identified and a special performance-test has to be implemented.
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
index 0770aa35..32721f0d 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java
@@ -77,16 +77,13 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
"ERROR: [400] exactly one of debitorRel and debitorRelUuid must be supplied, but found both");
Validate.isTrue(body.getDebitorRel() != null || body.getDebitorRelUuid() != null,
"ERROR: [400] exactly one of debitorRel and debitorRelUuid must be supplied, but found none");
- Validate.isTrue(body.getDebitorRel() == null ||
- body.getDebitorRel().getType() == null || DEBITOR.name().equals(body.getDebitorRel().getType()),
- "ERROR: [400] debitorRel.type must be '"+DEBITOR.name()+"' or null for default");
Validate.isTrue(body.getDebitorRel() == null || body.getDebitorRel().getMark() == null,
"ERROR: [400] debitorRel.mark must be null");
final var entityToSave = mapper.map(body, HsOfficeDebitorEntity.class);
- if ( body.getDebitorRel() != null ) {
- body.getDebitorRel().setType(DEBITOR.name());
+ if (body.getDebitorRel() != null) {
final var debitorRel = mapper.map("debitorRel.", body.getDebitorRel(), HsOfficeRelationRealEntity.class);
+ debitorRel.setType(DEBITOR);
entityValidator.validateEntityExists("debitorRel.anchorUuid", debitorRel.getAnchor());
entityValidator.validateEntityExists("debitorRel.holderUuid", debitorRel.getHolder());
entityValidator.validateEntityExists("debitorRel.contactUuid", debitorRel.getContact());
@@ -95,7 +92,10 @@ public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
final var debitorRelOptional = relrealRepo.findByUuid(body.getDebitorRelUuid());
debitorRelOptional.ifPresentOrElse(
debitorRel -> {entityToSave.setDebitorRel(relrealRepo.save(debitorRel));},
- () -> { throw new ValidationException("Unable to find RealRelation by debitorRelUuid: " + body.getDebitorRelUuid());});
+ () -> {
+ throw new ValidationException(
+ "Unable to find RealRelation by debitorRelUuid: " + body.getDebitorRelUuid());
+ });
}
final var savedEntity = debitorRepo.save(entityToSave);
diff --git a/src/main/java/net/hostsharing/hsadminng/reflection/AnnotationFinder.java b/src/main/java/net/hostsharing/hsadminng/reflection/AnnotationFinder.java
new file mode 100644
index 00000000..9a626e43
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/reflection/AnnotationFinder.java
@@ -0,0 +1,44 @@
+package net.hostsharing.hsadminng.reflection;
+
+import lombok.SneakyThrows;
+import lombok.experimental.UtilityClass;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.Optional;
+
+import static java.util.Optional.empty;
+
+@UtilityClass
+public class AnnotationFinder {
+
+ @SneakyThrows
+ public static Optional findCallerAnnotation(
+ final Class annotationClassToFind,
+ final Class extends Annotation> annotationClassToStopLookup
+ ) {
+ for (var element : Thread.currentThread().getStackTrace()) {
+ final var clazz = Class.forName(element.getClassName());
+ final var method = getMethodFromStackElement(clazz, element);
+
+ // Check if the method is annotated with the desired annotation
+ if (method != null) {
+ if (method.isAnnotationPresent(annotationClassToFind)) {
+ return Optional.of(method.getAnnotation(annotationClassToFind));
+ } else if (method.isAnnotationPresent(annotationClassToStopLookup)) {
+ return empty();
+ }
+ }
+ }
+ return empty();
+ }
+
+ private static Method getMethodFromStackElement(Class> clazz, StackTraceElement element) {
+ for (var method : clazz.getDeclaredMethods()) {
+ if (method.getName().equals(element.getMethodName())) {
+ return method;
+ }
+ }
+ return null;
+ }
+}
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
index f38644c1..ed4d8c26 100644
--- 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
@@ -17,10 +17,8 @@ components:
minimum: 1000000
maximum: 9999999
debitorNumberSuffix:
- type: integer
- format: int8
- minimum: 00
- maximum: 99
+ type: string
+ pattern: '^[0-9][0-9]$'
partner:
$ref: 'hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner'
billable:
@@ -76,15 +74,13 @@ components:
type: object
properties:
debitorRel:
- $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationInsert'
+ $ref: 'hs-office-relation-schemas.yaml#/components/schemas/HsOfficeRelationSubInsert'
debitorRelUuid:
type: string
format: uuid
debitorNumberSuffix:
- type: integer
- format: int8
- minimum: 00
- maximum: 99
+ type: string
+ pattern: '^[0-9][0-9]$'
billable:
type: boolean
vatId:
diff --git a/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml
index e0448a6f..cbad776c 100644
--- a/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml
+++ b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml
@@ -41,6 +41,7 @@ components:
format: uuid
nullable: true
+ # arbitrary relation with explicit type
HsOfficeRelationInsert:
type: object
properties:
@@ -64,3 +65,24 @@ components:
- holderUuid
- type
- contactUuid
+
+ # relation created as a sub-element with implicitly known type
+ HsOfficeRelationSubInsert:
+ type: object
+ properties:
+ anchorUuid:
+ type: string
+ format: uuid
+ holderUuid:
+ type: string
+ format: uuid
+ mark:
+ type: string
+ nullable: true
+ contactUuid:
+ type: string
+ format: uuid
+ required:
+ - anchorUuid
+ - holderUuid
+ - contactUuid
diff --git a/src/main/resources/db/changelog/2-rbactest/202-rbactest-package/2023-rbactest-package-rbac.sql b/src/main/resources/db/changelog/2-rbactest/202-rbactest-package/2023-rbactest-package-rbac.sql
index 2d2e9804..ac5604eb 100644
--- a/src/main/resources/db/changelog/2-rbactest/202-rbactest-package/2023-rbactest-package-rbac.sql
+++ b/src/main/resources/db/changelog/2-rbactest/202-rbactest-package/2023-rbactest-package-rbac.sql
@@ -36,7 +36,7 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM rbactest.customer WHERE uuid = NEW.customerUuid INTO newCustomer;
- assert newCustomer.uuid is not null, format('newCustomer must not be null for NEW.customerUuid = %s', NEW.customerUuid);
+ assert newCustomer.uuid is not null, format('newCustomer must not be null for NEW.customerUuid = %s of package', NEW.customerUuid);
perform rbac.defineRoleWithGrants(
@@ -102,10 +102,10 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM rbactest.customer WHERE uuid = OLD.customerUuid INTO oldCustomer;
- assert oldCustomer.uuid is not null, format('oldCustomer must not be null for OLD.customerUuid = %s', OLD.customerUuid);
+ assert oldCustomer.uuid is not null, format('oldCustomer must not be null for OLD.customerUuid = %s of package', OLD.customerUuid);
SELECT * FROM rbactest.customer WHERE uuid = NEW.customerUuid INTO newCustomer;
- assert newCustomer.uuid is not null, format('newCustomer must not be null for NEW.customerUuid = %s', NEW.customerUuid);
+ assert newCustomer.uuid is not null, format('newCustomer must not be null for NEW.customerUuid = %s of package', NEW.customerUuid);
if NEW.customerUuid <> OLD.customerUuid then
diff --git a/src/main/resources/db/changelog/2-rbactest/203-rbactest-domain/2033-rbactest-domain-rbac.sql b/src/main/resources/db/changelog/2-rbactest/203-rbactest-domain/2033-rbactest-domain-rbac.sql
index f2195485..2fc0d2a5 100644
--- a/src/main/resources/db/changelog/2-rbactest/203-rbactest-domain/2033-rbactest-domain-rbac.sql
+++ b/src/main/resources/db/changelog/2-rbactest/203-rbactest-domain/2033-rbactest-domain-rbac.sql
@@ -36,7 +36,7 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM rbactest.package WHERE uuid = NEW.packageUuid INTO newPackage;
- assert newPackage.uuid is not null, format('newPackage must not be null for NEW.packageUuid = %s', NEW.packageUuid);
+ assert newPackage.uuid is not null, format('newPackage must not be null for NEW.packageUuid = %s of domain', NEW.packageUuid);
perform rbac.defineRoleWithGrants(
@@ -98,10 +98,10 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM rbactest.package WHERE uuid = OLD.packageUuid INTO oldPackage;
- assert oldPackage.uuid is not null, format('oldPackage must not be null for OLD.packageUuid = %s', OLD.packageUuid);
+ assert oldPackage.uuid is not null, format('oldPackage must not be null for OLD.packageUuid = %s of domain', OLD.packageUuid);
SELECT * FROM rbactest.package WHERE uuid = NEW.packageUuid INTO newPackage;
- assert newPackage.uuid is not null, format('newPackage must not be null for NEW.packageUuid = %s', NEW.packageUuid);
+ assert newPackage.uuid is not null, format('newPackage must not be null for NEW.packageUuid = %s of domain', NEW.packageUuid);
if NEW.packageUuid <> OLD.packageUuid then
diff --git a/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql b/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql
index 5c100b33..08a395e0 100644
--- a/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql
+++ b/src/main/resources/db/changelog/5-hs-office/503-relation/5033-hs-office-relation-rbac.sql
@@ -38,13 +38,13 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_office.person WHERE uuid = NEW.holderUuid INTO newHolderPerson;
- assert newHolderPerson.uuid is not null, format('newHolderPerson must not be null for NEW.holderUuid = %s', NEW.holderUuid);
+ assert newHolderPerson.uuid is not null, format('newHolderPerson must not be null for NEW.holderUuid = %s of relation', NEW.holderUuid);
SELECT * FROM hs_office.person WHERE uuid = NEW.anchorUuid INTO newAnchorPerson;
- assert newAnchorPerson.uuid is not null, format('newAnchorPerson must not be null for NEW.anchorUuid = %s', NEW.anchorUuid);
+ assert newAnchorPerson.uuid is not null, format('newAnchorPerson must not be null for NEW.anchorUuid = %s of relation', NEW.anchorUuid);
SELECT * FROM hs_office.contact WHERE uuid = NEW.contactUuid INTO newContact;
- assert newContact.uuid is not null, format('newContact must not be null for NEW.contactUuid = %s', NEW.contactUuid);
+ assert newContact.uuid is not null, format('newContact must not be null for NEW.contactUuid = %s of relation', NEW.contactUuid);
perform rbac.defineRoleWithGrants(
diff --git a/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.sql b/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.sql
index 765c0f10..bfe295fe 100644
--- a/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.sql
+++ b/src/main/resources/db/changelog/5-hs-office/504-partner/5043-hs-office-partner-rbac.sql
@@ -37,10 +37,10 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_office.relation WHERE uuid = NEW.partnerRelUuid INTO newPartnerRel;
- assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerRelUuid = %s', NEW.partnerRelUuid);
+ assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerRelUuid = %s of partner', NEW.partnerRelUuid);
SELECT * FROM hs_office.partner_details WHERE uuid = NEW.detailsUuid INTO newPartnerDetails;
- assert newPartnerDetails.uuid is not null, format('newPartnerDetails must not be null for NEW.detailsUuid = %s', NEW.detailsUuid);
+ assert newPartnerDetails.uuid is not null, format('newPartnerDetails must not be null for NEW.detailsUuid = %s of partner', NEW.detailsUuid);
call rbac.grantPermissionToRole(rbac.createPermission(NEW.uuid, 'DELETE'), hs_office.relation_OWNER(newPartnerRel));
call rbac.grantPermissionToRole(rbac.createPermission(NEW.uuid, 'SELECT'), hs_office.relation_TENANT(newPartnerRel));
@@ -96,16 +96,16 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_office.relation WHERE uuid = OLD.partnerRelUuid INTO oldPartnerRel;
- assert oldPartnerRel.uuid is not null, format('oldPartnerRel must not be null for OLD.partnerRelUuid = %s', OLD.partnerRelUuid);
+ assert oldPartnerRel.uuid is not null, format('oldPartnerRel must not be null for OLD.partnerRelUuid = %s of partner', OLD.partnerRelUuid);
SELECT * FROM hs_office.relation WHERE uuid = NEW.partnerRelUuid INTO newPartnerRel;
- assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerRelUuid = %s', NEW.partnerRelUuid);
+ assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerRelUuid = %s of partner', NEW.partnerRelUuid);
SELECT * FROM hs_office.partner_details WHERE uuid = OLD.detailsUuid INTO oldPartnerDetails;
- assert oldPartnerDetails.uuid is not null, format('oldPartnerDetails must not be null for OLD.detailsUuid = %s', OLD.detailsUuid);
+ assert oldPartnerDetails.uuid is not null, format('oldPartnerDetails must not be null for OLD.detailsUuid = %s of partner', OLD.detailsUuid);
SELECT * FROM hs_office.partner_details WHERE uuid = NEW.detailsUuid INTO newPartnerDetails;
- assert newPartnerDetails.uuid is not null, format('newPartnerDetails must not be null for NEW.detailsUuid = %s', NEW.detailsUuid);
+ assert newPartnerDetails.uuid is not null, format('newPartnerDetails must not be null for NEW.detailsUuid = %s of partner', NEW.detailsUuid);
if NEW.partnerRelUuid <> OLD.partnerRelUuid then
diff --git a/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.sql b/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.sql
index 746dd38f..6a65dd39 100644
--- a/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.sql
+++ b/src/main/resources/db/changelog/5-hs-office/506-debitor/5063-hs-office-debitor-rbac.sql
@@ -44,10 +44,10 @@ begin
WHERE partnerRel.type = 'PARTNER'
AND NEW.debitorRelUuid = debitorRel.uuid
INTO newPartnerRel;
- assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.debitorRelUuid = %s', NEW.debitorRelUuid);
+ assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.debitorRelUuid = %s of debitor', NEW.debitorRelUuid);
SELECT * FROM hs_office.relation WHERE uuid = NEW.debitorRelUuid INTO newDebitorRel;
- assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorRelUuid = %s', NEW.debitorRelUuid);
+ assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorRelUuid = %s of debitor', NEW.debitorRelUuid);
SELECT * FROM hs_office.bankaccount WHERE uuid = NEW.refundBankAccountUuid INTO newRefundBankAccount;
diff --git a/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.sql b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.sql
index 15e7c589..f22a826b 100644
--- a/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.sql
+++ b/src/main/resources/db/changelog/5-hs-office/507-sepamandate/5073-hs-office-sepamandate-rbac.sql
@@ -37,14 +37,14 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_office.bankaccount WHERE uuid = NEW.bankAccountUuid INTO newBankAccount;
- assert newBankAccount.uuid is not null, format('newBankAccount must not be null for NEW.bankAccountUuid = %s', NEW.bankAccountUuid);
+ assert newBankAccount.uuid is not null, format('newBankAccount must not be null for NEW.bankAccountUuid = %s of sepamandate', NEW.bankAccountUuid);
SELECT debitorRel.*
FROM hs_office.relation debitorRel
JOIN hs_office.debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid
WHERE debitor.uuid = NEW.debitorUuid
INTO newDebitorRel;
- assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorUuid = %s', NEW.debitorUuid);
+ assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorUuid = %s of sepamandate', NEW.debitorUuid);
perform rbac.defineRoleWithGrants(
diff --git a/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.sql b/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.sql
index 41587e36..306dbced 100644
--- a/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.sql
+++ b/src/main/resources/db/changelog/5-hs-office/510-membership/5103-hs-office-membership-rbac.sql
@@ -40,7 +40,7 @@ begin
JOIN hs_office.relation AS partnerRel ON partnerRel.uuid = partner.partnerRelUuid
WHERE partner.uuid = NEW.partnerUuid
INTO newPartnerRel;
- assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerUuid = %s', NEW.partnerUuid);
+ assert newPartnerRel.uuid is not null, format('newPartnerRel must not be null for NEW.partnerUuid = %s of membership', NEW.partnerUuid);
perform rbac.defineRoleWithGrants(
diff --git a/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.sql b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.sql
index 911faa94..e7cc8811 100644
--- a/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.sql
+++ b/src/main/resources/db/changelog/5-hs-office/511-coopshares/5113-hs-office-coopshares-rbac.sql
@@ -36,7 +36,7 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_office.membership WHERE uuid = NEW.membershipUuid INTO newMembership;
- assert newMembership.uuid is not null, format('newMembership must not be null for NEW.membershipUuid = %s', NEW.membershipUuid);
+ assert newMembership.uuid is not null, format('newMembership must not be null for NEW.membershipUuid = %s of coopshares', NEW.membershipUuid);
call rbac.grantPermissionToRole(rbac.createPermission(NEW.uuid, 'SELECT'), hs_office.membership_AGENT(newMembership));
call rbac.grantPermissionToRole(rbac.createPermission(NEW.uuid, 'UPDATE'), hs_office.membership_ADMIN(newMembership));
diff --git a/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.sql b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.sql
index 1800b842..f5647823 100644
--- a/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.sql
+++ b/src/main/resources/db/changelog/5-hs-office/512-coopassets/5123-hs-office-coopassets-rbac.sql
@@ -36,7 +36,7 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_office.membership WHERE uuid = NEW.membershipUuid INTO newMembership;
- assert newMembership.uuid is not null, format('newMembership must not be null for NEW.membershipUuid = %s', NEW.membershipUuid);
+ assert newMembership.uuid is not null, format('newMembership must not be null for NEW.membershipUuid = %s of coopasset', NEW.membershipUuid);
call rbac.grantPermissionToRole(rbac.createPermission(NEW.uuid, 'SELECT'), hs_office.membership_AGENT(newMembership));
call rbac.grantPermissionToRole(rbac.createPermission(NEW.uuid, 'UPDATE'), hs_office.membership_ADMIN(newMembership));
diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql
index 88a83fbe..ade16515 100644
--- a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql
+++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql
@@ -37,14 +37,14 @@ begin
call rbac.enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_office.debitor WHERE uuid = NEW.debitorUuid INTO newDebitor;
- assert newDebitor.uuid is not null, format('newDebitor must not be null for NEW.debitorUuid = %s', NEW.debitorUuid);
+ assert newDebitor.uuid is not null, format('newDebitor must not be null for NEW.debitorUuid = %s of project', NEW.debitorUuid);
SELECT debitorRel.*
FROM hs_office.relation debitorRel
JOIN hs_office.debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid
WHERE debitor.uuid = NEW.debitorUuid
INTO newDebitorRel;
- assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorUuid = %s', NEW.debitorUuid);
+ assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorUuid = %s or project', NEW.debitorUuid);
perform rbac.defineRoleWithGrants(
diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
index 5041f2eb..3c3cae0c 100644
--- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
@@ -46,6 +46,7 @@ public class ArchitectureTest {
"..lambda",
"..generated..",
"..persistence..",
+ "..reflection",
"..system..",
"..validation..",
"..hs.office.bankaccount",
@@ -54,6 +55,7 @@ public class ArchitectureTest {
"..hs.office.coopshares",
"..hs.office.debitor",
"..hs.office.membership",
+ "..hs.office.scenarios..",
"..hs.migration",
"..hs.office.partner",
"..hs.office.person",
@@ -96,7 +98,7 @@ public class ArchitectureTest {
public static final ArchRule testClassesAreProperlyNamed = classes()
.that().haveSimpleNameEndingWith("Test")
.and().doNotHaveModifier(ABSTRACT)
- .should().haveNameMatching(".*(UnitTest|RestTest|IntegrationTest|AcceptanceTest|ArchitectureTest)$");
+ .should().haveNameMatching(".*(UnitTest|RestTest|IntegrationTest|AcceptanceTest|ScenarioTest|ArchitectureTest)$");
@ArchTest
@SuppressWarnings("unused")
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
index d0954b6a..58b715da 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java
@@ -12,7 +12,6 @@ import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealRepository;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
-import org.json.JSONException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
@@ -76,7 +75,7 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
class ListDebitors {
@Test
- void globalAdmin_withoutAssumedRoles_canViewAllDebitors_ifNoCriteriaGiven() throws JSONException {
+ void globalAdmin_withoutAssumedRoles_canViewAllDebitors_ifNoCriteriaGiven() {
RestAssured // @formatter:off
.given()
@@ -112,7 +111,7 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
}
},
"debitorNumber": 1000111,
- "debitorNumberSuffix": 11,
+ "debitorNumberSuffix": "11",
"partner": {
"partnerNumber": 10001,
"partnerRel": {
@@ -167,7 +166,7 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
}
},
"debitorNumber": 1000212,
- "debitorNumberSuffix": 12,
+ "debitorNumberSuffix": "12",
"partner": {
"partnerNumber": 10002,
"partnerRel": {
@@ -201,7 +200,7 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
}
},
"debitorNumber": 1000313,
- "debitorNumberSuffix": 13,
+ "debitorNumberSuffix": "13",
"partner": {
"partnerNumber": 10003,
"partnerRel": {
@@ -334,7 +333,6 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("""
{
"debitorRel": {
- "type": "DEBITOR",
"anchorUuid": "%s",
"holderUuid": "%s",
"contactUuid": "%s"
@@ -386,7 +384,6 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("""
{
"debitorRel": {
- "type": "DEBITOR",
"anchorUuid": "%s",
"holderUuid": "%s",
"contactUuid": "%s"
@@ -469,7 +466,7 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
}
},
"debitorNumber": 1000111,
- "debitorNumberSuffix": 11,
+ "debitorNumberSuffix": "11",
"partner": {
"partnerNumber": 10001,
"partnerRel": {
@@ -581,7 +578,7 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
"contact": { "caption": "fourth contact" }
},
"debitorNumber": 10004${debitorNumberSuffix},
- "debitorNumberSuffix": ${debitorNumberSuffix},
+ "debitorNumberSuffix": "${debitorNumberSuffix}",
"partner": {
"partnerNumber": 10004,
"partnerRel": {
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java
new file mode 100644
index 00000000..85e86127
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java
@@ -0,0 +1,240 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import net.hostsharing.hsadminng.HsadminNgApplication;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateExternalDebitorForPartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSelfDebitorForPartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSepaMandateForDebitor;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.DeleteSepaMandateForDebitor;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.DontDeleteDefaultDebitor;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.InvalidateSepaMandateForDebitor;
+import net.hostsharing.hsadminng.hs.office.scenarios.membership.CreateMembership;
+import net.hostsharing.hsadminng.hs.office.scenarios.partner.AddOperationsContactToPartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.partner.CreatePartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.DeleteDebitor;
+import net.hostsharing.hsadminng.hs.office.scenarios.partner.DeletePartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.partner.AddRepresentativeToPartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.subscription.RemoveOperationsContactFromPartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeToMailinglist;
+import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist;
+import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.annotation.DirtiesContext;
+
+@Tag("scenarioTest")
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ classes = { HsadminNgApplication.class, JpaAttempt.class },
+ properties = {
+ "spring.datasource.url=${HSADMINNG_POSTGRES_JDBC_URL:jdbc:tc:postgresql:15.5-bookworm:///scenariosTC}",
+ "spring.datasource.username=${HSADMINNG_POSTGRES_ADMIN_USERNAME:ADMIN}",
+ "spring.datasource.password=${HSADMINNG_POSTGRES_ADMIN_PASSWORD:password}",
+ "hsadminng.superuser=${HSADMINNG_SUPERUSER:superuser-alex@hostsharing.net}"
+ }
+)
+@DirtiesContext
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class HsOfficeScenarioTests extends ScenarioTest {
+
+ @Test
+ @Order(1010)
+ @Produces(explicitly = "Partner: Test AG", implicitly = {"Person: Test AG", "Contact: Test AG - Board of Directors"})
+ void shouldCreatePartner() {
+ new CreatePartner(this)
+ .given("partnerNumber", 31010)
+ .given("personType", "LEGAL_PERSON")
+ .given("tradeName", "Test AG")
+ .given("contactCaption", "Test AG - Board of Directors")
+ .given("emailAddress", "board-of-directors@test-ag.example.org")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(1020)
+ @Requires("Person: Test AG")
+ @Produces("Representative: Tracy Trust for Test AG")
+ void shouldAddRepresentativeToPartner() {
+ new AddRepresentativeToPartner(this)
+ .given("partnerPersonTradeName", "Test AG")
+ .given("representativeFamilyName", "Trust")
+ .given("representativeGivenName", "Tracy")
+ .given("representativePostalAddress", """
+ An der Alster 100
+ 20000 Hamburg
+ """)
+ .given("representativePhoneNumber", "+49 40 123456")
+ .given("representativeEMailAddress", "tracy.trust@example.org")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(1030)
+ @Requires("Person: Test AG")
+ @Produces("Operations-Contact: Dennis Krause for Test AG")
+ void shouldAddOperationsContactToPartner() {
+ new AddOperationsContactToPartner(this)
+ .given("partnerPersonTradeName", "Test AG")
+ .given("operationsContactFamilyName", "Krause")
+ .given("operationsContactGivenName", "Dennis")
+ .given("operationsContactPhoneNumber", "+49 9932 587741")
+ .given("operationsContactEMailAddress", "dennis.krause@example.org")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(1039)
+ @Requires("Operations-Contact: Dennis Krause for Test AG")
+ void shouldRemoveOperationsContactFromPartner() {
+ new RemoveOperationsContactFromPartner(this)
+ .given("operationsContactPerson", "Dennis Krause")
+ .doRun();
+ }
+
+ @Test
+ @Order(1090)
+ void shouldDeletePartner() {
+ new DeletePartner(this)
+ .given("partnerNumber", 31020)
+ .doRun();
+ }
+
+ @Test
+ @Order(2010)
+ @Requires("Partner: Test AG")
+ @Produces("Debitor: Test AG - main debitor")
+ void shouldCreateSelfDebitorForPartner() {
+ new CreateSelfDebitorForPartner(this, "Debitor: Test AG - main debitor")
+ .given("partnerPersonTradeName", "Test AG")
+ .given("billingContactCaption", "Test AG - billing department")
+ .given("billingContactEmailAddress", "billing@test-ag.example.org")
+ .given("debitorNumberSuffix", "00") // TODO.impl: could be assigned automatically, but is not yet
+ .given("billable", true)
+ .given("vatId", "VAT123456")
+ .given("vatCountryCode", "DE")
+ .given("vatBusiness", true)
+ .given("vatReverseCharge", false)
+ .given("defaultPrefix", "tst")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(2011)
+ @Requires("Person: Test AG")
+ @Produces("Debitor: Billing GmbH")
+ void shouldCreateExternalDebitorForPartner() {
+ new CreateExternalDebitorForPartner(this)
+ .given("partnerPersonTradeName", "Test AG")
+ .given("billingContactCaption", "Billing GmbH - billing department")
+ .given("billingContactEmailAddress", "billing@test-ag.example.org")
+ .given("debitorNumberSuffix", "01")
+ .given("billable", true)
+ .given("vatId", "VAT123456")
+ .given("vatCountryCode", "DE")
+ .given("vatBusiness", true)
+ .given("vatReverseCharge", false)
+ .given("defaultPrefix", "tsx")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(2020)
+ @Requires("Person: Test AG")
+ void shouldDeleteDebitor() {
+ new DeleteDebitor(this)
+ .given("partnerNumber", 31020)
+ .given("debitorSuffix", "02")
+ .doRun();
+ }
+
+ @Test
+ @Order(2020)
+ @Requires("Debitor: Test AG - main debitor")
+ @Disabled("see TODO.spec in DontDeleteDefaultDebitor")
+ void shouldNotDeleteDefaultDebitor() {
+ new DontDeleteDefaultDebitor(this)
+ .given("partnerNumber", 31020)
+ .given("debitorSuffix", "00")
+ .doRun();
+ }
+
+ @Test
+ @Order(3100)
+ @Requires("Debitor: Test AG - main debitor")
+ @Produces("SEPA-Mandate: Test AG")
+ void shouldCreateSepaMandateForDebitor() {
+ new CreateSepaMandateForDebitor(this)
+ .given("debitor", "Test AG")
+ .given("memberNumberSuffix", "00")
+ .given("validFrom", "2024-10-15")
+ .given("membershipFeeBillable", "true")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(3108)
+ @Requires("SEPA-Mandate: Test AG")
+ void shouldInvalidateSepaMandateForDebitor() {
+ new InvalidateSepaMandateForDebitor(this)
+ .given("sepaMandateUuid", "%{SEPA-Mandate: Test AG}")
+ .given("validUntil", "2025-09-30")
+ .doRun();
+ }
+
+ @Test
+ @Order(3109)
+ @Requires("SEPA-Mandate: Test AG")
+ void shouldDeleteSepaMandateForDebitor() {
+ new DeleteSepaMandateForDebitor(this)
+ .given("sepaMandateUuid", "%{SEPA-Mandate: Test AG}")
+ .doRun();
+ }
+
+ @Test
+ @Order(4000)
+ @Requires("Partner: Test AG")
+ void shouldCreateMembershipForPartner() {
+ new CreateMembership(this)
+ .given("partnerName", "Test AG")
+ .given("memberNumberSuffix", "00")
+ .given("validFrom", "2024-10-15")
+ .given("membershipFeeBillable", "true")
+ .doRun();
+ }
+
+ @Test
+ @Order(5000)
+ @Requires("Person: Test AG")
+ @Produces("Subscription: Michael Miller to operations-announce")
+ void shouldSubscribeNewPersonAndContactToMailinglist() {
+ new SubscribeToMailinglist(this)
+ // TODO.spec: do we need the personType? or is an operational contact always a natural person? what about distribution lists?
+ .given("partnerPersonTradeName", "Test AG")
+ .given("subscriberFamilyName", "Miller")
+ .given("subscriberGivenName", "Michael")
+ .given("subscriberEMailAddress", "michael.miller@example.org")
+ .given("mailingList", "operations-announce")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(5001)
+ @Requires("Subscription: Michael Miller to operations-announce")
+ void shouldUnsubscribeNewPersonAndContactToMailinglist() {
+ new UnsubscribeFromMailinglist(this)
+ .given("mailingList", "operations-announce")
+ .given("subscriberEMailAddress", "michael.miller@example.org")
+ .doRun();
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/Produces.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/Produces.java
new file mode 100644
index 00000000..07bc4e47
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/Produces.java
@@ -0,0 +1,15 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@Target(METHOD)
+@Retention(RUNTIME)
+public @interface Produces {
+ String value() default ""; // same as explicitly, makes it possible to omit the property name
+ String explicitly() default ""; // same as value
+ String[] implicitly() default {};
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/README.md b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/README.md
new file mode 100644
index 00000000..36e7c3c8
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/README.md
@@ -0,0 +1,66 @@
+# UseCase-Tests
+
+We define UseCase-tests as test for business-scenarios.
+They test positive (successful) scenarios by using the REST-API.
+
+Running these tests also creates test-reports which can be used as documentation about the necessary REST-calls for each scenario.
+
+Clarification: Acceptance tests also test at the REST-API level but are more technical and also test negative (error-) scenarios.
+
+## ... extends ScenarioTest
+
+Each test-method in subclasses of ScenarioTest describes a business-scenario,
+each utilizing a main-use-case and given example data for the scenario.
+
+To reduce the number of API-calls, intermediate results can be re-used.
+This is controlled by two annotations:
+
+### @Produces(....)
+
+This annotation tells the test-runner that this scenario produces certain business object for re-use.
+The UUID of the new business objects are stored in a key-value map using the provided keys.
+
+There are two variants of this annotation:
+
+#### A Single Business Object
+```
+@Produces("key")
+```
+
+This variant is used when there is just a single business-object produced by the use-case.
+
+#### Multiple Business Objects
+
+```
+@Produces(explicitly = "main-key", implicitly = {"other-key", ...})
+```
+
+This variant is used when multiple business-objects are produced by the use-case,
+e.g. a Relation, a Person and a Contact.
+The UUID of the business-object produced by the main-use-case gets stored as the key after "explicitly",
+the others are listed after "implicitly";
+if there is just one, leave out the surrounding braces.
+
+### @Requires(...)
+
+This annotation tells the test-runner that which business objects are required before this scenario can run.
+
+Each subset must be produced by the same producer-method.
+
+
+## ... extends UseCase
+
+These classes consist of two parts:
+
+### Prerequisites of the Use-Case
+
+The constructor may create prerequisites via `required(...)`.
+These do not really belong to the use-case itself,
+e.g. create business objects which, in the context of that use-case, would already exist.
+
+This is similar to @Requires(...) just that no other test scenario produces this prerequisite.
+Here, use-cases can be re-used, usually with different data.
+
+### The Use-Case Itself
+
+The use-case
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/Requires.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/Requires.java
new file mode 100644
index 00000000..59ea21ec
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/Requires.java
@@ -0,0 +1,13 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@Target(METHOD)
+@Retention(RUNTIME)
+public @interface Requires {
+ String value();
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/ScenarioTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/ScenarioTest.java
new file mode 100644
index 00000000..efc11c54
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/ScenarioTest.java
@@ -0,0 +1,180 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import lombok.SneakyThrows;
+import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
+import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository;
+import net.hostsharing.hsadminng.lambda.Reducer;
+import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
+import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
+import org.apache.commons.collections4.SetUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.server.LocalServerPort;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import static java.util.Arrays.asList;
+import static java.util.Optional.ofNullable;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public abstract class ScenarioTest extends ContextBasedTest {
+
+ final static String RUN_AS_USER = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented
+
+ record Alias>(Class useCase, UUID uuid) {
+
+ @Override
+ public String toString() {
+ return uuid.toString();
+ }
+ }
+
+ private final static Map> aliases = new HashMap<>();
+ private final static Map properties = new HashMap<>();
+
+ public final TestReport testReport = new TestReport(aliases);
+
+ @LocalServerPort
+ Integer port;
+
+ @Autowired
+ HsOfficePersonRepository personRepo;
+
+ @Autowired
+ JpaAttempt jpaAttempt;
+
+ @SneakyThrows
+ @BeforeEach
+ void init(final TestInfo testInfo) {
+ createHostsharingPerson();
+ try {
+ testInfo.getTestMethod().ifPresent(this::callRequiredProducers);
+ testReport.createTestLogMarkdownFile(testInfo);
+ } catch (Exception exc) {
+ throw exc;
+ }
+ }
+
+ @AfterEach
+ void cleanup() { // final TestInfo testInfo
+ properties.clear();
+ // FIXME: Delete all aliases as well to force HTTP GET queries in each scenario?
+ testReport.close();
+ }
+
+ private void createHostsharingPerson() {
+ jpaAttempt.transacted(() ->
+ {
+ context.define("superuser-alex@hostsharing.net");
+ aliases.put(
+ "Person: Hostsharing eG",
+ new Alias<>(
+ null,
+ personRepo.findPersonByOptionalNameLike("Hostsharing eG")
+ .stream()
+ .map(HsOfficePersonEntity::getUuid)
+ .reduce(Reducer::toSingleElement).orElseThrow())
+ );
+ }
+ );
+ }
+
+ @SneakyThrows
+ private void callRequiredProducers(final Method currentTestMethod) {
+ final var testMethodRequired = Optional.of(currentTestMethod)
+ .map(m -> m.getAnnotation(Requires.class))
+ .map(Requires::value)
+ .orElse(null);
+ if (testMethodRequired != null) {
+ for (Method potentialProducerMethod : getClass().getDeclaredMethods()) {
+ final var producesAnnot = potentialProducerMethod.getAnnotation(Produces.class);
+ if (producesAnnot != null) {
+ final var testMethodProduces = allOf(
+ producesAnnot.value(),
+ producesAnnot.explicitly(),
+ producesAnnot.implicitly());
+ // @formatter:off
+ if ( // that method can produce something required
+ testMethodProduces.contains(testMethodRequired) &&
+
+ // and it does not produce anything we already have (would cause errors)
+ SetUtils.intersection(testMethodProduces, knowVariables().keySet()).isEmpty()
+ ) {
+ // then we recursively produce the pre-requisites of the producer method
+ callRequiredProducers(potentialProducerMethod);
+
+ // and finally we call the producer method
+ potentialProducerMethod.invoke(this);
+ }
+ // @formatter:on
+ }
+ }
+ }
+ }
+
+ private Set allOf(final String value, final String explicitly, final String[] implicitly) {
+ final var all = new HashSet();
+ if (!value.isEmpty()) {
+ all.add(value);
+ }
+ if (!explicitly.isEmpty()) {
+ all.add(explicitly);
+ }
+ all.addAll(asList(implicitly));
+ return all;
+ }
+
+ static boolean containsAlias(final String alias) {
+ return aliases.containsKey(alias);
+ }
+
+ static UUID uuid(final String nameWithPlaceholders) {
+ final var resoledName = resolve(nameWithPlaceholders);
+ final UUID alias = ofNullable(knowVariables().get(resoledName)).filter(v -> v instanceof UUID).map(UUID.class::cast).orElse(null);
+ assertThat(alias).as("alias '" + resoledName + "' not found in aliases nor in properties [" +
+ knowVariables().keySet().stream().map(v -> "'" + v + "'").collect(Collectors.joining(", ")) + "]"
+ ).isNotNull();
+ return alias;
+ }
+
+ static void putAlias(final String name, final Alias> value) {
+ aliases.put(name, value);
+ }
+
+ static void putProperty(final String name, final Object value) {
+ properties.put(name, (value instanceof String string) ? resolveTyped(string) : value);
+ }
+
+ static Map knowVariables() {
+ final var map = new LinkedHashMap();
+ ScenarioTest.aliases.forEach((key, value) -> map.put(key, value.uuid()));
+ map.putAll(ScenarioTest.properties);
+ return map;
+ }
+
+ public static String resolve(final String text) {
+ final var resolved = new TemplateResolver(text, ScenarioTest.knowVariables()).resolve();
+ return resolved;
+ }
+
+ public static Object resolveTyped(final String text) {
+ final var resolved = resolve(text);
+ try {
+ return UUID.fromString(resolved);
+ } catch (final IllegalArgumentException e) {
+ // ignore and just use the String value
+ }
+ return resolved;
+ }
+
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TemplateResolver.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TemplateResolver.java
new file mode 100644
index 00000000..ccb8c96d
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TemplateResolver.java
@@ -0,0 +1,138 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import java.util.Map;
+
+public class TemplateResolver {
+
+ private final String template;
+ private final Map properties;
+ private final StringBuilder resolved = new StringBuilder();
+ private int position = 0;
+
+ public TemplateResolver(final String template, final Map properties) {
+ this.template = template;
+ this.properties = properties;
+ }
+
+ String resolve() {
+ copy();
+ return resolved.toString();
+ }
+
+ private void copy() {
+ while (hasMoreChars()) {
+ if ((currentChar() == '$' || currentChar() == '%') && nextChar() == '{') {
+ startPlaceholder(currentChar());
+ } else {
+ resolved.append(fetchChar());
+ }
+ }
+ }
+
+ private boolean hasMoreChars() {
+ return position < template.length();
+ }
+
+ private void startPlaceholder(final char intro) {
+ skipChars(intro + "{");
+ int nested = 0;
+ final var placeholder = new StringBuilder();
+ while (nested > 0 || currentChar() != '}') {
+ if (currentChar() == '}') {
+ --nested;
+ placeholder.append(fetchChar());
+ } else if ((currentChar() == '$' || currentChar() == '%') && nextChar() == '{') {
+ ++nested;
+ placeholder.append(fetchChar());
+ } else {
+ placeholder.append(fetchChar());
+ }
+ }
+ final var name = new TemplateResolver(placeholder.toString(), properties).resolve();
+ final var value = propVal(name);
+ if ( intro == '%') {
+ resolved.append(value);
+ } else {
+ resolved.append(optionallyQuoted(value));
+ }
+ skipChar('}');
+ }
+
+ private Object propVal(final String name) {
+ final var val = properties.get(name);
+ if (val == null) {
+ throw new IllegalStateException("Missing required property: " + name);
+ }
+ return val;
+ }
+
+ private void skipChar(final char expectedChar) {
+ if (currentChar() != expectedChar) {
+ throw new IllegalStateException("expected '" + expectedChar + "' but got '" + currentChar() + "'");
+ }
+ ++position;
+ }
+
+ private void skipChars(final String expectedChars) {
+ final var nextChars = template.substring(position, position + expectedChars.length());
+ if ( !nextChars.equals(expectedChars) ) {
+ throw new IllegalStateException("expected '" + expectedChars + "' but got '" + nextChars + "'");
+ }
+ position += expectedChars.length();
+ }
+
+ private char fetchChar() {
+ if ((position+1) > template.length()) {
+ throw new IllegalStateException("no more characters. resolved so far: " + resolved);
+ }
+ final var currentChar = currentChar();
+ ++position;
+ return currentChar;
+ }
+
+ private char currentChar() {
+ if (position >= template.length()) {
+ throw new IllegalStateException("no more characters, maybe closing bracelet missing in template: '''\n" + template + "\n'''");
+ }
+ return template.charAt(position);
+ }
+
+ private char nextChar() {
+ if ((position+1) >= template.length()) {
+ throw new IllegalStateException("no more characters. resolved so far: " + resolved);
+ }
+ return template.charAt(position+1);
+ }
+
+ private static String optionallyQuoted(final Object value) {
+ return switch (value) {
+ case Boolean bool -> bool.toString();
+ case Number number -> number.toString();
+ case String string -> "\"" + string.replace("\n", "\\n") + "\"";
+ default -> "\"" + value + "\"";
+ };
+ }
+
+ public static void main(String[] args) {
+ System.out.println(
+ new TemplateResolver("""
+ etwas davor,
+
+ ${einfacher Platzhalter},
+ ${verschachtelter %{Name}},
+
+ und nochmal ohne Quotes:
+
+ %{einfacher Platzhalter},
+ %{verschachtelter %{Name}},
+
+ etwas danach.
+ """,
+ Map.ofEntries(
+ Map.entry("Name", "placeholder"),
+ Map.entry("einfacher Platzhalter", "simple placeholder"),
+ Map.entry("verschachtelter placeholder", "nested placeholder")
+ )).resolve());
+
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TestReport.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TestReport.java
new file mode 100644
index 00000000..02123a14
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TestReport.java
@@ -0,0 +1,90 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import lombok.SneakyThrows;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.TestInfo;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.lang.reflect.Method;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class TestReport {
+
+ private final Map aliases;
+ private final StringBuilder markdownLog = new StringBuilder(); // records everything for debugging purposes
+
+ private PrintWriter markdownReport;
+ private int silent; // do not print anything to test-report if >0
+
+ public TestReport(final Map aliases) {
+ this.aliases = aliases;
+ }
+
+ public void createTestLogMarkdownFile(final TestInfo testInfo) throws IOException {
+ final var testMethodName = testInfo.getTestMethod().map(Method::getName).orElseThrow();
+ final var testMethodOrder = testInfo.getTestMethod().map(m -> m.getAnnotation(Order.class).value()).orElseThrow();
+ assertThat(new File("doc/scenarios/").isDirectory() || new File("doc/scenarios/").mkdirs()).as("mkdir doc/scenarios/").isTrue();
+ markdownReport = new PrintWriter(new FileWriter("doc/scenarios/" + testMethodOrder + "-" + testMethodName + ".md"));
+ print("## Scenario #" + testInfo.getTestMethod().map(TestReport::orderNumber).orElseThrow() + ": " +
+ testMethodName.replaceAll("([a-z])([A-Z]+)", "$1 $2"));
+ }
+
+ @SneakyThrows
+ public void print(final String output) {
+
+ final var outputWithCommentsForUuids = appendUUIDKey(output);
+
+ // for tests executed due to @Requires/@Produces there is no markdownFile yet
+ if (markdownReport != null && silent == 0) {
+ markdownReport.print(outputWithCommentsForUuids);
+ }
+
+ // but the debugLog should contain all output, even if silent
+ markdownLog.append(outputWithCommentsForUuids);
+ }
+
+ public void printLine(final String output) {
+ print(output + "\n");
+ }
+
+ public void printPara(final String output) {
+ printLine("\n" +output + "\n");
+ }
+
+ public void close() {
+ markdownReport.close();
+ }
+
+ private static Object orderNumber(final Method method) {
+ return method.getAnnotation(Order.class).value();
+ }
+
+ private String appendUUIDKey(String multilineText) {
+ final var lines = multilineText.split("\\r?\\n");
+ final var result = new StringBuilder();
+
+ for (String line : lines) {
+ for (Map.Entry entry : aliases.entrySet()) {
+ final var uuidString = entry.getValue().toString();
+ if (line.contains(uuidString)) {
+ line = line + " // " + entry.getKey();
+ break; // only add comment for one UUID per row (in our case, there is only one per row)
+ }
+ }
+ result.append(line).append("\n");
+ }
+ return result.toString();
+ }
+
+ void silent(final Runnable code) {
+ silent++;
+ code.run();
+ silent--;
+ }
+
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCase.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCase.java
new file mode 100644
index 00000000..710e4ae1
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCase.java
@@ -0,0 +1,319 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.jayway.jsonpath.JsonPath;
+import io.restassured.http.ContentType;
+import lombok.Getter;
+import lombok.SneakyThrows;
+import net.hostsharing.hsadminng.reflection.AnnotationFinder;
+import org.apache.commons.collections4.map.LinkedMap;
+import org.hibernate.AssertionFailure;
+import org.jetbrains.annotations.Nullable;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static java.net.URLEncoder.encode;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.platform.commons.util.StringUtils.isBlank;
+import static org.junit.platform.commons.util.StringUtils.isNotBlank;
+
+public abstract class UseCase> {
+
+ private static final HttpClient client = HttpClient.newHttpClient();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ protected final ScenarioTest testSuite;
+ private final TestReport testReport;
+ private final Map>> requirements = new LinkedMap<>();
+ private final String resultAlias;
+ private final Map givenProperties = new LinkedHashMap<>();
+
+ private String nextTitle; // just temporary to override resultAlias for sub-use-cases
+
+ public UseCase(final ScenarioTest testSuite) {
+ this(testSuite, getResultAliasFromProducesAnnotationInCallStack());
+ }
+
+ public UseCase(final ScenarioTest testSuite, final String resultAlias) {
+ this.testSuite = testSuite;
+ this.testReport = testSuite.testReport;
+ this.resultAlias = resultAlias;
+ if (resultAlias != null) {
+ testReport.printPara("### UseCase " + title(resultAlias));
+ }
+ }
+
+ public final void requires(final String alias, final Function> useCaseFactory) {
+ if (!ScenarioTest.containsAlias(alias)) {
+ requirements.put(alias, useCaseFactory);
+ }
+ }
+
+ public final HttpResponse doRun() {
+ testReport.printPara("### Given Properties");
+ testReport.printLine("""
+ | name | value |
+ |------|-------|""");
+ givenProperties.forEach((key, value) ->
+ testReport.printLine("| " + key + " | " + value.toString().replace("\n", "
") + " |"));
+ testReport.printLine("");
+ testReport.silent(() ->
+ requirements.forEach((alias, factory) -> {
+ if (!ScenarioTest.containsAlias(alias)) {
+ factory.apply(alias).run().keep();
+ }
+ })
+ );
+ return run();
+ }
+
+ protected abstract HttpResponse run();
+
+ public final UseCase given(final String propName, final Object propValue) {
+ givenProperties.put(propName, propValue);
+ ScenarioTest.putProperty(propName, propValue);
+ return this;
+ }
+
+ public final JsonTemplate usingJsonBody(final String jsonTemplate) {
+ return new JsonTemplate(jsonTemplate);
+ }
+
+ public final void obtain(
+ final String alias,
+ final Supplier http,
+ final Function extractor,
+ final String... extraInfo) {
+ withTitle(ScenarioTest.resolve(alias), () -> {
+ http.get().keep(extractor);
+ Arrays.stream(extraInfo).forEach(testReport::printPara);
+ });
+ }
+
+ public final void obtain(final String alias, final Supplier http, final String... extraInfo) {
+ withTitle(ScenarioTest.resolve(alias), () -> {
+ http.get().keep();
+ Arrays.stream(extraInfo).forEach(testReport::printPara);
+ });
+ }
+
+ private void withTitle(final String title, final Runnable code) {
+ this.nextTitle = title;
+ code.run();
+ this.nextTitle = null;
+ }
+
+ @SneakyThrows
+ public final HttpResponse httpGet(final String uriPath) {
+ final var request = HttpRequest.newBuilder()
+ .GET()
+ .uri(new URI("http://localhost:" + testSuite.port + uriPath))
+ .header("current-subject", ScenarioTest.RUN_AS_USER)
+ .timeout(Duration.ofSeconds(10))
+ .build();
+ final var response = client.send(request, BodyHandlers.ofString());
+ return new HttpResponse(HttpMethod.GET, uriPath, null, response);
+ }
+
+ @SneakyThrows
+ public final HttpResponse httpPost(final String uriPath, final JsonTemplate bodyJsonTemplate) {
+ final var requestBody = bodyJsonTemplate.resolvePlaceholders();
+ final var request = HttpRequest.newBuilder()
+ .POST(BodyPublishers.ofString(requestBody))
+ .uri(new URI("http://localhost:" + testSuite.port + uriPath))
+ .header("Content-Type", "application/json")
+ .header("current-subject", ScenarioTest.RUN_AS_USER)
+ .timeout(Duration.ofSeconds(10))
+ .build();
+ final var response = client.send(request, BodyHandlers.ofString());
+ return new HttpResponse(HttpMethod.POST, uriPath, requestBody, response);
+ }
+
+ @SneakyThrows
+ public final HttpResponse httpPatch(final String uriPath, final JsonTemplate bodyJsonTemplate) {
+ final var requestBody = bodyJsonTemplate.resolvePlaceholders();
+ final var request = HttpRequest.newBuilder()
+ .method(HttpMethod.PATCH.toString(), BodyPublishers.ofString(requestBody))
+ .uri(new URI("http://localhost:" + testSuite.port + uriPath))
+ .header("Content-Type", "application/json")
+ .header("current-subject", ScenarioTest.RUN_AS_USER)
+ .timeout(Duration.ofSeconds(10))
+ .build();
+ final var response = client.send(request, BodyHandlers.ofString());
+ return new HttpResponse(HttpMethod.PATCH, uriPath, requestBody, response);
+ }
+
+ @SneakyThrows
+ public final HttpResponse httpDelete(final String uriPath) {
+ final var request = HttpRequest.newBuilder()
+ .DELETE()
+ .uri(new URI("http://localhost:" + testSuite.port + uriPath))
+ .header("Content-Type", "application/json")
+ .header("current-subject", ScenarioTest.RUN_AS_USER)
+ .timeout(Duration.ofSeconds(10))
+ .build();
+ final var response = client.send(request, BodyHandlers.ofString());
+ return new HttpResponse(HttpMethod.DELETE, uriPath, null, response);
+ }
+
+ public final UUID uuid(final String alias) {
+ return ScenarioTest.uuid(alias);
+ }
+
+ public String uriEncoded(final String text) {
+ return encode(ScenarioTest.resolve(text));
+ }
+
+ public static class JsonTemplate {
+
+ private final String template;
+
+ private JsonTemplate(final String jsonTemplate) {
+ this.template = jsonTemplate;
+ }
+
+ String resolvePlaceholders() {
+ return ScenarioTest.resolve(template);
+ }
+ }
+
+ public class HttpResponse {
+
+ @Getter
+ private final java.net.http.HttpResponse response;
+
+ @Getter
+ private final HttpStatus status;
+
+ private UUID locationUuid;
+
+ @SneakyThrows
+ public HttpResponse(
+ final HttpMethod httpMethod,
+ final String uri,
+ final String requestBody,
+ final java.net.http.HttpResponse response
+ ) {
+ this.response = response;
+ this.status = HttpStatus.valueOf(response.statusCode());
+ if (this.status == HttpStatus.CREATED) {
+ final var location = response.headers().firstValue("Location").orElseThrow();
+ assertThat(location).startsWith("http://localhost:");
+ locationUuid = UUID.fromString(location.substring(location.lastIndexOf('/') + 1));
+ }
+
+ reportRequestAndResponse(httpMethod, uri, requestBody);
+ }
+
+ public HttpResponse expecting(final HttpStatus httpStatus) {
+ assertThat(HttpStatus.valueOf(response.statusCode())).isEqualTo(httpStatus);
+ return this;
+ }
+
+ public HttpResponse expecting(final ContentType contentType) {
+ assertThat(response.headers().firstValue("content-type"))
+ .contains(contentType.toString());
+ return this;
+ }
+
+ public void keep(final Function extractor) {
+ final var alias = nextTitle != null ? nextTitle : resultAlias;
+ assertThat(alias).as("cannot keep result, no alias found").isNotNull();
+
+ final var value = extractor.apply(this);
+ ScenarioTest.putAlias(
+ alias,
+ new ScenarioTest.Alias<>(UseCase.this.getClass(), UUID.fromString(value)));
+ }
+
+ public void keep() {
+ final var alias = nextTitle != null ? nextTitle : resultAlias;
+ assertThat(alias).as("cannot keep result, no alias found").isNotNull();
+ ScenarioTest.putAlias(
+ alias,
+ new ScenarioTest.Alias<>(UseCase.this.getClass(), locationUuid));
+ }
+
+ @SneakyThrows
+ public HttpResponse expectArrayElements(final int expectedElementCount) {
+ final var rootNode = objectMapper.readTree(response.body());
+ assertThat(rootNode.isArray()).as("array expected, but got: " + response.body()).isTrue();
+
+ final var root = (List>) objectMapper.readValue(response.body(), new TypeReference>() {
+ });
+ assertThat(root.size()).as("unexpected number of array elements").isEqualTo(expectedElementCount);
+ return this;
+ }
+
+ @SneakyThrows
+ public String getFromBody(final String path) {
+ return JsonPath.parse(response.body()).read(path);
+ }
+
+ @SneakyThrows
+ private void reportRequestAndResponse(final HttpMethod httpMethod, final String uri, final String requestBody) {
+
+ // the title
+ if (nextTitle != null) {
+ testReport.printLine("\n### " + nextTitle + "\n");
+ } else if (resultAlias != null) {
+ testReport.printLine("\n### " + resultAlias + "\n");
+ }
+
+ // the request
+ testReport.printLine("```");
+ testReport.printLine(httpMethod.name() + " " + uri);
+ testReport.printLine((requestBody != null ? requestBody.trim() : ""));
+
+ // the response
+ testReport.printLine("=> status: " + status + " " + (locationUuid != null ? locationUuid : ""));
+ if (httpMethod == HttpMethod.GET || status.isError()) {
+ final var jsonNode = objectMapper.readTree(response.body());
+ final var prettyJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode);
+ testReport.printLine(prettyJson);
+ }
+ testReport.printLine("```");
+ testReport.printLine("");
+ }
+ }
+
+ protected T self() {
+ //noinspection unchecked
+ return (T) this;
+ }
+
+ private static @Nullable String getResultAliasFromProducesAnnotationInCallStack() {
+ return AnnotationFinder.findCallerAnnotation(Produces.class, Test.class)
+ .map(produces -> oneOf(produces.value(), produces.explicitly()))
+ .orElse(null);
+ }
+
+ private static String oneOf(final String one, final String another) {
+ if (isNotBlank(one) && isBlank(another)) {
+ return one;
+ } else if (isBlank(one) && isNotBlank(another)) {
+ return another;
+ }
+ throw new AssertionFailure("exactly one value required, but got '" + one + "' and '" + another + "'");
+ }
+
+ private String title(String resultAlias) {
+ return getClass().getSimpleName().replaceAll("([a-z])([A-Z]+)", "$1 $2") + " => " + resultAlias;
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateExternalDebitorForPartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateExternalDebitorForPartner.java
new file mode 100644
index 00000000..e9afdcc2
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateExternalDebitorForPartner.java
@@ -0,0 +1,74 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import net.hostsharing.hsadminng.hs.office.scenarios.person.CreatePerson;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.CREATED;
+import static org.springframework.http.HttpStatus.OK;
+
+public class CreateExternalDebitorForPartner extends UseCase {
+
+ public CreateExternalDebitorForPartner(final ScenarioTest testSuite) {
+ super(testSuite);
+
+ requires("Person: Billing GmbH", alias -> new CreatePerson(testSuite, alias)
+ .given("personType", "LEGAL_PERSON")
+ .given("tradeName", "Billing GmbH")
+ );
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("Person: %{partnerPersonTradeName}", () ->
+ httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "In production data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ obtain("BankAccount: Billing GmbH - refund bank account", () ->
+ httpPost("/api/hs/office/bankaccounts", usingJsonBody("""
+ {
+ "holder": "Billing GmbH - refund bank account",
+ "iban": "DE02120300000000202051",
+ "bic": "BYLADEM1001"
+ }
+ """))
+ .expecting(CREATED).expecting(JSON)
+ );
+
+ obtain("Contact: Billing GmbH - Test AG billing", () ->
+ httpPost("/api/hs/office/contacts", usingJsonBody("""
+ {
+ "caption": "Billing GmbH, billing for Test AG",
+ "emailAddresses": {
+ "main": "test-ag@billing-GmbH.example.com"
+ }
+ }
+ """))
+ .expecting(CREATED).expecting(JSON)
+ );
+
+ return httpPost("/api/hs/office/debitors", usingJsonBody("""
+ {
+ "debitorRel": {
+ "anchorUuid": ${Person: %{partnerPersonTradeName}},
+ "holderUuid": ${Person: Billing GmbH},
+ "contactUuid": ${Contact: Billing GmbH - Test AG billing}
+ },
+ "debitorNumberSuffix": ${debitorNumberSuffix},
+ "billable": ${billable},
+ "vatId": ${vatId},
+ "vatCountryCode": ${vatCountryCode},
+ "vatBusiness": ${vatBusiness},
+ "vatReverseCharge": ${vatReverseCharge},
+ "refundBankAccountUuid": ${BankAccount: Billing GmbH - refund bank account},
+ "defaultPrefix": ${defaultPrefix}
+ }
+ """))
+ .expecting(CREATED).expecting(JSON);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateSelfDebitorForPartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateSelfDebitorForPartner.java
new file mode 100644
index 00000000..91a21a00
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateSelfDebitorForPartner.java
@@ -0,0 +1,69 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.CREATED;
+import static org.springframework.http.HttpStatus.OK;
+
+public class CreateSelfDebitorForPartner extends UseCase {
+
+ public CreateSelfDebitorForPartner(final ScenarioTest testSuite, final String resultAlias) {
+ super(testSuite, resultAlias);
+ }
+
+ @Override
+ protected HttpResponse run() {
+ obtain("partnerPersonUuid", () ->
+ httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerPersonTradeName}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].holder.uuid"),
+ "In production data this query could result in multiple outputs. In that case, you have to find out which is the right one.",
+ "**HINT**: With production data, you might get multiple results and have to decide which is the right one."
+ );
+
+ obtain("BankAccount: Test AG - refund bank account", () ->
+ httpPost("/api/hs/office/bankaccounts", usingJsonBody("""
+ {
+ "holder": "Test AG - refund bank account",
+ "iban": "DE88100900001234567892",
+ "bic": "BEVODEBB"
+ }
+ """))
+ .expecting(CREATED).expecting(JSON)
+ );
+
+ obtain("Contact: Test AG - billing department", () ->
+ httpPost("/api/hs/office/contacts", usingJsonBody("""
+ {
+ "caption": ${billingContactCaption},
+ "emailAddresses": {
+ "main": ${billingContactEmailAddress}
+ }
+ }
+ """))
+ .expecting(CREATED).expecting(JSON)
+ );
+
+ return httpPost("/api/hs/office/debitors", usingJsonBody("""
+ {
+ "debitorRel": {
+ "anchorUuid": ${partnerPersonUuid},
+ "holderUuid": ${partnerPersonUuid},
+ "contactUuid": ${Contact: Test AG - billing department}
+ },
+ "debitorNumberSuffix": ${debitorNumberSuffix},
+ "billable": ${billable},
+ "vatId": ${vatId},
+ "vatCountryCode": ${vatCountryCode},
+ "vatBusiness": ${vatBusiness},
+ "vatReverseCharge": ${vatReverseCharge},
+ "refundBankAccountUuid": ${BankAccount: Test AG - refund bank account},
+ "defaultPrefix": ${defaultPrefix}
+ }
+ """))
+ .expecting(CREATED).expecting(JSON);
+ }
+
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateSepaMandateForDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateSepaMandateForDebitor.java
new file mode 100644
index 00000000..9bbba7a6
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateSepaMandateForDebitor.java
@@ -0,0 +1,39 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.CREATED;
+
+public class CreateSepaMandateForDebitor extends UseCase {
+
+ public CreateSepaMandateForDebitor(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+ obtain("BankAccount: Test AG - debit bank account", () ->
+ httpPost("/api/hs/office/bankaccounts", usingJsonBody("""
+ {
+ "holder": "Test AG - debit bank account",
+ "iban": "DE02701500000000594937",
+ "bic": "SSKMDEMM"
+ }
+ """))
+ .expecting(CREATED).expecting(JSON)
+ );
+
+ return httpPost("/api/hs/office/sepamandates", usingJsonBody("""
+ {
+ "debitorUuid": ${Debitor: Test AG - main debitor},
+ "bankAccountUuid": ${BankAccount: Test AG - debit bank account},
+ "reference": "Test AG - main debitor",
+ "agreement": "2022-10-12",
+ "validFrom": "2022-10-13"
+ }
+ """))
+ .expecting(CREATED).expecting(JSON);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteDebitor.java
new file mode 100644
index 00000000..016f1a75
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteDebitor.java
@@ -0,0 +1,31 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import org.springframework.http.HttpStatus;
+
+public class DeleteDebitor extends UseCase {
+
+ public DeleteDebitor(final ScenarioTest testSuite) {
+ super(testSuite);
+
+ requires("Debitor: Test AG - delete debitor", alias -> new CreateSelfDebitorForPartner(testSuite, alias)
+ .given("partnerPersonTradeName", "Test AG")
+ .given("billingContactCaption", "Test AG - billing department")
+ .given("billingContactEmailAddress", "billing@test-ag.example.org")
+ .given("debitorNumberSuffix", "%{debitorSuffix}")
+ .given("billable", true)
+ .given("vatId", "VAT123456")
+ .given("vatCountryCode", "DE")
+ .given("vatBusiness", true)
+ .given("vatReverseCharge", false)
+ .given("defaultPrefix", "tsy"));
+ }
+
+ @Override
+ protected HttpResponse run() {
+ httpDelete("/api/hs/office/debitors/" + uuid("Debitor: Test AG - delete debitor"))
+ .expecting(HttpStatus.NO_CONTENT);
+ return null;
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteSepaMandateForDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteSepaMandateForDebitor.java
new file mode 100644
index 00000000..e5c9b94a
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteSepaMandateForDebitor.java
@@ -0,0 +1,20 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import org.springframework.http.HttpStatus;
+
+
+public class DeleteSepaMandateForDebitor extends UseCase {
+
+ public DeleteSepaMandateForDebitor(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+ httpDelete("/api/hs/office/sepamandates/" + uuid("SEPA-Mandate: Test AG"))
+ .expecting(HttpStatus.NO_CONTENT);
+ return null;
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DontDeleteDefaultDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DontDeleteDefaultDebitor.java
new file mode 100644
index 00000000..82aae503
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DontDeleteDefaultDebitor.java
@@ -0,0 +1,20 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import org.springframework.http.HttpStatus;
+
+public class DontDeleteDefaultDebitor extends UseCase {
+
+ public DontDeleteDefaultDebitor(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+ httpDelete("/api/hs/office/debitors/" + uuid("Debitor: Test AG - main debitor"))
+ // TODO.spec: should be CONFLICT or CLIENT_ERROR for Debitor "00" - but how to delete Partners?
+ .expecting(HttpStatus.NO_CONTENT);
+ return null;
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/InvalidateSepaMandateForDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/InvalidateSepaMandateForDebitor.java
new file mode 100644
index 00000000..9d13706e
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/InvalidateSepaMandateForDebitor.java
@@ -0,0 +1,25 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.OK;
+
+public class InvalidateSepaMandateForDebitor extends UseCase {
+
+ public InvalidateSepaMandateForDebitor(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ return httpPatch("/api/hs/office/sepamandates/" + uuid("SEPA-Mandate: Test AG"), usingJsonBody("""
+ {
+ "validUntil": ${validUntil}
+ }
+ """))
+ .expecting(OK).expecting(JSON);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/CreateMembership.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/CreateMembership.java
new file mode 100644
index 00000000..5a28f4d4
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/membership/CreateMembership.java
@@ -0,0 +1,29 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.membership;
+
+import io.restassured.http.ContentType;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import org.springframework.http.HttpStatus;
+
+public class CreateMembership extends UseCase {
+
+ public CreateMembership(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+ obtain("Membership: %{partnerName} 00", () ->
+ httpPost("/api/hs/office/memberships", usingJsonBody("""
+ {
+ "partnerUuid": ${Partner: Test AG},
+ "memberNumberSuffix": ${memberNumberSuffix},
+ "validFrom": ${validFrom},
+ "membershipFeeBillable": ${membershipFeeBillable}
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
+ );
+ return null;
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/AddOperationsContactToPartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/AddOperationsContactToPartner.java
new file mode 100644
index 00000000..6e41ce76
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/AddOperationsContactToPartner.java
@@ -0,0 +1,67 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.partner;
+
+import io.restassured.http.ContentType;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import org.springframework.http.HttpStatus;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.CREATED;
+import static org.springframework.http.HttpStatus.OK;
+
+public class AddOperationsContactToPartner extends UseCase {
+
+ public AddOperationsContactToPartner(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("Person: %{partnerPersonTradeName}", () ->
+ httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "In production data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ obtain("Person: %{operationsContactGivenName} %{operationsContactFamilyName}", () ->
+ httpPost("/api/hs/office/persons", usingJsonBody("""
+ {
+ "personType": "NATURAL_PERSON",
+ "familyName": ${operationsContactFamilyName},
+ "givenName": ${operationsContactGivenName}
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON),
+ "Please check first if that person already exists, if so, use it's UUID below.",
+ "**HINT**: operations contacts are always connected to a partner-person, thus a person which is a holder of a partner-relation."
+ );
+
+ obtain("Contact: %{operationsContactGivenName} %{operationsContactFamilyName}", () ->
+ httpPost("/api/hs/office/contacts", usingJsonBody("""
+ {
+ "caption": "%{operationsContactGivenName} %{operationsContactFamilyName}",
+ "phoneNumbers": {
+ "main": ${operationsContactPhoneNumber}
+ },
+ "emailAddresses": {
+ "main": ${operationsContactEMailAddress}
+ }
+ }
+ """))
+ .expecting(CREATED).expecting(JSON),
+ "Please check first if that contact already exists, if so, use it's UUID below."
+ );
+
+ return httpPost("/api/hs/office/relations", usingJsonBody("""
+ {
+ "type": "OPERATIONS",
+ "anchorUuid": ${Person: %{partnerPersonTradeName}},
+ "holderUuid": ${Person: %{operationsContactGivenName} %{operationsContactFamilyName}},
+ "contactUuid": ${Contact: %{operationsContactGivenName} %{operationsContactFamilyName}}
+ }
+ """))
+ .expecting(CREATED).expecting(JSON);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/AddRepresentativeToPartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/AddRepresentativeToPartner.java
new file mode 100644
index 00000000..cb5c8136
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/AddRepresentativeToPartner.java
@@ -0,0 +1,68 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.partner;
+
+import io.restassured.http.ContentType;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import org.springframework.http.HttpStatus;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.CREATED;
+import static org.springframework.http.HttpStatus.OK;
+
+public class AddRepresentativeToPartner extends UseCase {
+
+ public AddRepresentativeToPartner(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("Person: %{partnerPersonTradeName}", () ->
+ httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "In production data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ obtain("Person: %{representativeGivenName} %{representativeFamilyName}", () ->
+ httpPost("/api/hs/office/persons", usingJsonBody("""
+ {
+ "personType": "NATURAL_PERSON",
+ "familyName": ${representativeFamilyName},
+ "givenName": ${representativeGivenName}
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON),
+ "Please check first if that person already exists, if so, use it's UUID below.",
+ "**HINT**: A representative is always a natural person and represents a non-natural-person."
+ );
+
+ obtain("Contact: %{representativeGivenName} %{representativeFamilyName}", () ->
+ httpPost("/api/hs/office/contacts", usingJsonBody("""
+ {
+ "caption": "%{representativeGivenName} %{representativeFamilyName}",
+ "postalAddress": ${representativePostalAddress},
+ "phoneNumbers": {
+ "main": ${representativePhoneNumber}
+ },
+ "emailAddresses": {
+ "main": ${representativeEMailAddress}
+ }
+ }
+ """))
+ .expecting(CREATED).expecting(JSON),
+ "Please check first if that contact already exists, if so, use it's UUID below."
+ );
+
+ return httpPost("/api/hs/office/relations", usingJsonBody("""
+ {
+ "type": "REPRESENTATIVE",
+ "anchorUuid": ${Person: %{partnerPersonTradeName}},
+ "holderUuid": ${Person: %{representativeGivenName} %{representativeFamilyName}},
+ "contactUuid": ${Contact: %{representativeGivenName} %{representativeFamilyName}}
+ }
+ """))
+ .expecting(CREATED).expecting(JSON);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/CreatePartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/CreatePartner.java
new file mode 100644
index 00000000..c96acbdf
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/CreatePartner.java
@@ -0,0 +1,69 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.partner;
+
+import io.restassured.http.ContentType;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import org.springframework.http.HttpStatus;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.OK;
+
+public class CreatePartner extends UseCase {
+
+ public CreatePartner(final ScenarioTest testSuite, final String resultAlias) {
+ super(testSuite, resultAlias);
+ }
+
+ public CreatePartner(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("Person: Hostsharing eG", () ->
+ httpGet("/api/hs/office/persons?name=Hostsharing+eG")
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "Even in production data we expect this query to return just a single result." // TODO.impl: add constraint?
+ );
+
+ obtain("Person: %{tradeName}", () ->
+ httpPost("/api/hs/office/persons", usingJsonBody("""
+ {
+ "personType": ${personType},
+ "tradeName": ${tradeName}
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
+ );
+
+ obtain("Contact: %{tradeName} - Board of Directors", () ->
+ httpPost("/api/hs/office/contacts", usingJsonBody("""
+ {
+ "caption": ${contactCaption},
+ "emailAddresses": {
+ "main": ${emailAddress}
+ }
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
+ );
+
+ return httpPost("/api/hs/office/partners", usingJsonBody("""
+ {
+ "partnerNumber": ${partnerNumber},
+ "partnerRel": {
+ "anchorUuid": ${Person: Hostsharing eG},
+ "holderUuid": ${Person: %{tradeName}},
+ "contactUuid": ${Contact: %{tradeName} - Board of Directors}
+ },
+ "details": {
+ "registrationOffice": "Registergericht Hamburg",
+ "registrationNumber": "1234567"
+ }
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/DeletePartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/DeletePartner.java
new file mode 100644
index 00000000..ae24dfd1
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/DeletePartner.java
@@ -0,0 +1,25 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.partner;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import org.springframework.http.HttpStatus;
+
+public class DeletePartner extends UseCase {
+
+ public DeletePartner(final ScenarioTest testSuite) {
+ super(testSuite);
+
+ requires("Partner: Delete AG", alias -> new CreatePartner(testSuite, alias)
+ .given("personType", "LEGAL_PERSON")
+ .given("tradeName", "Delete AG")
+ .given("contactCaption", "Delete AG - Board of Directors")
+ .given("emailAddress", "board-of-directors@delete-ag.example.org"));
+ }
+
+ @Override
+ protected HttpResponse run() {
+ httpDelete("/api/hs/office/partners/" + uuid("Partner: Delete AG"))
+ .expecting(HttpStatus.NO_CONTENT);
+ return null;
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/person/CreatePerson.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/person/CreatePerson.java
new file mode 100644
index 00000000..56db97e8
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/person/CreatePerson.java
@@ -0,0 +1,25 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.person;
+
+import io.restassured.http.ContentType;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import org.springframework.http.HttpStatus;
+
+public class CreatePerson extends UseCase {
+
+ public CreatePerson(final ScenarioTest testSuite, final String resultAlias) {
+ super(testSuite, resultAlias);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ return httpPost("/api/hs/office/persons", usingJsonBody("""
+ {
+ "personType": ${personType},
+ "tradeName": ${tradeName}
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/RemoveOperationsContactFromPartner.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/RemoveOperationsContactFromPartner.java
new file mode 100644
index 00000000..64584075
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/RemoveOperationsContactFromPartner.java
@@ -0,0 +1,29 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.NO_CONTENT;
+import static org.springframework.http.HttpStatus.OK;
+
+public class RemoveOperationsContactFromPartner extends UseCase {
+
+ public RemoveOperationsContactFromPartner(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("Operations-Contact: %{operationsContactPerson}", () ->
+ httpGet("/api/hs/office/relations?relationType=OPERATIONS&name=" + uriEncoded("%{operationsContactPerson}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "In production data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ return httpDelete("/api/hs/office/relations/" + uuid("Operations-Contact: %{operationsContactPerson}"))
+ .expecting(NO_CONTENT);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeToMailinglist.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeToMailinglist.java
new file mode 100644
index 00000000..3c84603f
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/SubscribeToMailinglist.java
@@ -0,0 +1,62 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
+
+import io.restassured.http.ContentType;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import org.springframework.http.HttpStatus;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.CREATED;
+import static org.springframework.http.HttpStatus.OK;
+
+public class SubscribeToMailinglist extends UseCase {
+
+ public SubscribeToMailinglist(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("Person: %{partnerPersonTradeName}", () ->
+ httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "In production data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ obtain("Person: %{subscriberGivenName} %{subscriberFamilyName}", () ->
+ httpPost("/api/hs/office/persons", usingJsonBody("""
+ {
+ "personType": "NATURAL_PERSON",
+ "familyName": ${subscriberFamilyName},
+ "givenName": ${subscriberGivenName}
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
+ );
+
+ obtain("Contact: %{subscriberGivenName} %{subscriberFamilyName}", () ->
+ httpPost("/api/hs/office/contacts", usingJsonBody("""
+ {
+ "caption": "%{subscriberGivenName} %{subscriberFamilyName}",
+ "emailAddresses": {
+ "main": ${subscriberEMailAddress}
+ }
+ }
+ """))
+ .expecting(CREATED).expecting(JSON)
+ );
+
+ return httpPost("/api/hs/office/relations", usingJsonBody("""
+ {
+ "type": "SUBSCRIBER",
+ "mark": ${mailingList},
+ "anchorUuid": ${Person: %{partnerPersonTradeName}},
+ "holderUuid": ${Person: %{subscriberGivenName} %{subscriberFamilyName}},
+ "contactUuid": ${Contact: %{subscriberGivenName} %{subscriberFamilyName}}
+ }
+ """))
+ .expecting(CREATED).expecting(JSON);
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/UnsubscribeFromMailinglist.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/UnsubscribeFromMailinglist.java
new file mode 100644
index 00000000..6f059902
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/UnsubscribeFromMailinglist.java
@@ -0,0 +1,31 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.NO_CONTENT;
+import static org.springframework.http.HttpStatus.OK;
+
+public class UnsubscribeFromMailinglist extends UseCase {
+
+ public UnsubscribeFromMailinglist(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("Subscription: %{subscriberEMailAddress}", () ->
+ httpGet("/api/hs/office/relations?relationType=SUBSCRIBER" +
+ "&mark=" + uriEncoded("%{mailingList}") +
+ "&contactData=" + uriEncoded("%{subscriberEMailAddress}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "In production data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ return httpDelete("/api/hs/office/relations/" + uuid("Subscription: %{subscriberEMailAddress}"))
+ .expecting(NO_CONTENT);
+ }
+}