diff --git a/Jenkinsfile b/Jenkinsfile
index 5c1722d6..dc466d28 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -26,9 +26,35 @@ pipeline {
}
}
- stage ('Compile & Test') {
+ stage ('Compile') {
steps {
- sh './gradlew clean check --no-daemon -x pitest -x dependencyCheckAnalyze'
+ sh './gradlew clean processSpring compileJava compileTestJava --no-daemon'
+ }
+ }
+
+ stage ('Tests') {
+ parallel {
+ stage('Unit-/Integration/Acceptance-Tests') {
+ steps {
+ sh './gradlew check --no-daemon -x pitest -x dependencyCheckAnalyze -x importOfficeData -x importHostingAssets'
+ }
+ }
+ stage('Import-Tests') {
+ steps {
+ sh './gradlew importOfficeData importHostingAssets --no-daemon'
+ }
+ }
+ stage ('Scenario-Tests') {
+ steps {
+ sh './gradlew scenarioTests --no-daemon'
+ }
+ }
+ }
+ }
+
+ stage ('Check') {
+ steps {
+ sh './gradlew check -x pitest -x dependencyCheckAnalyze --no-daemon'
}
}
}
@@ -45,6 +71,12 @@ pipeline {
sourcePattern: 'src/main/java'
)
+ // archive scenario-test reports in HTML format
+ sh '''
+ ./gradlew convertMarkdownToHtml
+ '''
+ archiveArtifacts artifacts: 'doc/scenarios/*.html', allowEmptyArchive: true
+
// cleanup workspace
cleanWs()
}
diff --git a/build.gradle b/build.gradle
index 96b16673..ed7a290d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -255,7 +255,7 @@ test {
'net.hostsharing.hsadminng.**.generated.**',
]
useJUnitPlatform {
- excludeTags 'import'
+ excludeTags 'importOfficeData', 'importHostingData', 'scenarioTest'
}
}
jacocoTestReport {
@@ -344,6 +344,17 @@ tasks.register('importHostingAssets', Test) {
mustRunAfter spotlessJava
}
+tasks.register('scenarioTests', Test) {
+ useJUnitPlatform {
+ includeTags 'scenarioTest'
+ }
+
+ group 'verification'
+ description 'run the import jobs as tests'
+
+ mustRunAfter spotlessJava
+}
+
// pitest mutation testing
pitest {
targetClasses = ['net.hostsharing.hsadminng.**']
@@ -391,3 +402,45 @@ tasks.named("dependencyUpdates").configure {
isNonStable(it.candidate.version)
}
}
+
+
+// Generate HTML from Markdown scenario-test-reports using Pandoc:
+tasks.register('convertMarkdownToHtml') {
+ description = 'Generates HTML from Markdown scenario-test-reports using Pandoc.'
+ group = 'Conversion'
+
+ // Define the template file and input directory
+ def templateFile = file('doc/scenarios/template.html')
+
+ // Task configuration and execution
+ doFirst {
+ // Check if pandoc is installed
+ try {
+ exec {
+ commandLine 'pandoc', '--version'
+ }
+ } catch (Exception) {
+ throw new GradleException("Pandoc is not installed or not found in the system path.")
+ }
+
+ // Check if the template file exists
+ if (!templateFile.exists()) {
+ throw new GradleException("Template file 'doc/scenarios/template.html' not found.")
+ }
+ }
+
+ doLast {
+ // Gather all Markdown files in the current directory
+ fileTree(dir: '.', include: 'doc/scenarios/*.md').each { file ->
+ // Corrected way to create the output file path
+ def outputFile = new File(file.parent, file.name.replaceAll(/\.md$/, '.html'))
+
+ // Execute pandoc for each markdown file
+ exec {
+ commandLine 'pandoc', file.absolutePath, '--template', templateFile.absolutePath, '-o', outputFile.absolutePath
+ }
+
+ println "Converted ${file.name} to ${outputFile.name}"
+ }
+ }
+}
diff --git a/doc/scenarios/template.html b/doc/scenarios/template.html
new file mode 100644
index 00000000..1bb16e52
--- /dev/null
+++ b/doc/scenarios/template.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+$for(author-meta)$
+
+$endfor$
+$if(date-meta)$
+
+$endif$
+ $if(title-prefix)$$title-prefix$ - $endif$$pagetitle$
+
+$if(quotes)$
+
+$endif$
+$if(highlighting-css)$
+
+$endif$
+$for(css)$
+
+$endfor$
+$if(math)$
+ $math$
+$endif$
+$for(header-includes)$
+ $header-includes$
+$endfor$
+
+
+
+
+ $if(title)$
+
+
+
+
$title$
+
+ $for(author)$
+ $author$
+ $endfor$
+ $if(date)$
+ $date$
+ $endif$
+
+
+
+
+ $endif$
+
+
+ $if(toc)$
+
+ $endif$
+
+
+ $if(abstract)$
+
$abstract-title$
+ $abstract$
+ $endif$
+
+ $for(include-before)$
+ $include-before$
+ $endfor$
+$body$
+ $for(include-after)$
+ $include-after$
+ $endfor$
+
+
+
+
+
+
+
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/etc/jenkinsAgent.Dockerfile b/etc/jenkinsAgent.Dockerfile
index 648e2f8e..f06e9e4f 100644
--- a/etc/jenkinsAgent.Dockerfile
+++ b/etc/jenkinsAgent.Dockerfile
@@ -1,10 +1,6 @@
FROM eclipse-temurin:21-jdk
RUN apt-get update && \
- apt-get install -y bind9-utils && \
+ apt-get install -y bind9-utils pandoc && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
-
-# RUN mkdir /opt/app
-# COPY japp.jar /opt
-# CMD ["java", "-jar", "/opt/app/japp.jar"]
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java
index 62519731..a4267f53 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java
@@ -54,8 +54,14 @@ public class HsOfficeContact implements Stringifyable, BaseEntity postalAddress = new HashMap<>();
+
+ @Transient
+ private PatchableMapWrapper postalAddressWrapper;
@Builder.Default
@Setter(AccessLevel.NONE)
@@ -75,6 +81,17 @@ public class HsOfficeContact implements Stringifyable, BaseEntity phoneNumbersWrapper;
+ public PatchableMapWrapper getPostalAddress() {
+ return PatchableMapWrapper.of(
+ postalAddressWrapper,
+ (newWrapper) -> {postalAddressWrapper = newWrapper;},
+ postalAddress);
+ }
+
+ public void putPostalAddress(Map newPostalAddress) {
+ getPostalAddress().assign(newPostalAddress);
+ }
+
public PatchableMapWrapper getEmailAddresses() {
return PatchableMapWrapper.of(
emailAddressesWrapper,
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java
index e08e6bae..356aa950 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java
@@ -18,7 +18,8 @@ class HsOfficeContactEntityPatcher implements EntityPatcher entity.getPostalAddress().patch(KeyValueMap.from(resource.getPostalAddress())));
Optional.ofNullable(resource.getEmailAddresses())
.ifPresent(r -> entity.getEmailAddresses().patch(KeyValueMap.from(resource.getEmailAddresses())));
Optional.ofNullable(resource.getPhoneNumbers())
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/hs/office/relation/HsOfficeRelationRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java
index 96d4b8d5..cce31305 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRbacRepository.java
@@ -47,7 +47,7 @@ public interface HsOfficeRelationRbacRepository extends Repository 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/501-contact/5010-hs-office-contact.sql b/src/main/resources/db/changelog/5-hs-office/501-contact/5010-hs-office-contact.sql
index eeb33f4d..b9e6bb67 100644
--- a/src/main/resources/db/changelog/5-hs-office/501-contact/5010-hs-office-contact.sql
+++ b/src/main/resources/db/changelog/5-hs-office/501-contact/5010-hs-office-contact.sql
@@ -9,7 +9,7 @@ create table if not exists hs_office.contact
uuid uuid unique references rbac.object (uuid) initially deferred,
version int not null default 0,
caption varchar(128) not null,
- postalAddress text,
+ postalAddress jsonb not null,
emailAddresses jsonb not null,
phoneNumbers jsonb not null
);
diff --git a/src/main/resources/db/changelog/5-hs-office/501-contact/5018-hs-office-contact-test-data.sql b/src/main/resources/db/changelog/5-hs-office/501-contact/5018-hs-office-contact-test-data.sql
index db621862..036fd7e2 100644
--- a/src/main/resources/db/changelog/5-hs-office/501-contact/5018-hs-office-contact-test-data.sql
+++ b/src/main/resources/db/changelog/5-hs-office/501-contact/5018-hs-office-contact-test-data.sql
@@ -11,7 +11,6 @@
create or replace procedure hs_office.contact_create_test_data(contCaption varchar)
language plpgsql as $$
declare
- postalAddr varchar;
emailAddr varchar;
begin
emailAddr = 'contact-admin@' || base.cleanIdentifier(contCaption) || '.example.com';
@@ -19,14 +18,18 @@ begin
perform rbac.create_subject(emailAddr);
call base.defineContext('creating contact test-data', null, emailAddr);
- postalAddr := E'Vorname Nachname\nStraße Hnr\nPLZ Stadt';
-
raise notice 'creating test contact: %', contCaption;
insert
into hs_office.contact (caption, postaladdress, emailaddresses, phonenumbers)
values (
contCaption,
- postalAddr,
+ ( '{ ' ||
+-- '"name": "' || contCaption || '",' ||
+-- '"street": "Somewhere 1",' ||
+-- '"zipcode": "12345",' ||
+-- '"city": "Where-Ever",' ||
+ '"country": "Germany"' ||
+ '}')::jsonb,
('{ "main": "' || emailAddr || '" }')::jsonb,
('{ "phone_office": "+49 123 1234567" }')::jsonb
);
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/hosting/asset/HsHostingAssetControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java
index 9dc5741b..c50e2dfd 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java
@@ -110,7 +110,6 @@ public class HsHostingAssetControllerRestTest {
"caption": "some fake cloud-server",
"alarmContact": {
"caption": "some contact",
- "postalAddress": "address of some contact",
"emailAddresses": {
"main": "some-contact@example.com"
}
@@ -141,7 +140,6 @@ public class HsHostingAssetControllerRestTest {
"caption": "some fake managed-server",
"alarmContact": {
"caption": "some contact",
- "postalAddress": "address of some contact",
"emailAddresses": {
"main": "some-contact@example.com"
}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java
index dd745b36..858e302d 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java
@@ -1161,7 +1161,7 @@ public abstract class BaseOfficeDataImport extends CsvDataImport {
contactRecord.getString("last_name"),
contactRecord.getString("firma")));
contact.putEmailAddresses(Map.of("main", contactRecord.getString("email")));
- contact.setPostalAddress(toAddress(contactRecord));
+ contact.putPostalAddress(toAddress(contactRecord));
contact.putPhoneNumbers(toPhoneNumbers(contactRecord));
contacts.put(contactRecord.getInteger("contact_id"), contact);
@@ -1181,36 +1181,23 @@ public abstract class BaseOfficeDataImport extends CsvDataImport {
return phoneNumbers;
}
- private String toAddress(final Record rec) {
- final var result = new StringBuilder();
+ private Map toAddress(final Record rec) {
+ final var result = new LinkedHashMap();
final var name = toName(
rec.getString("salut"),
rec.getString("title"),
rec.getString("first_name"),
rec.getString("last_name"));
if (isNotBlank(name))
- result.append(name + "\n");
+ result.put("name", name);
if (isNotBlank(rec.getString("firma")))
- result.append(rec.getString("firma") + "\n");
- if (isNotBlank(rec.getString("co")))
- result.append("c/o " + rec.getString("co") + "\n");
- if (isNotBlank(rec.getString("street")))
- result.append(rec.getString("street") + "\n");
- final var zipcodeAndCity = toZipcodeAndCity(rec);
- if (isNotBlank(zipcodeAndCity))
- result.append(zipcodeAndCity + "\n");
- return result.toString();
- }
+ result.put("firm", name);
- private String toZipcodeAndCity(final Record rec) {
- final var result = new StringBuilder();
- if (isNotBlank(rec.getString("country")))
- result.append(rec.getString("country") + " ");
- if (isNotBlank(rec.getString("zipcode")))
- result.append(rec.getString("zipcode") + " ");
- if (isNotBlank(rec.getString("city")))
- result.append(rec.getString("city"));
- return result.toString();
+ List.of("co", "street", "zipcode", "city", "country").forEach(key -> {
+ if (isNotBlank(rec.getString(key)))
+ result.put(key, rec.getString(key));
+ });
+ return result;
}
private String toCaption(
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java
index 10dd8c75..19bb80a3 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java
@@ -21,10 +21,13 @@ import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.ThreadLocalRandom;
+import static java.util.Map.entry;
import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.hasEntry;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
@@ -214,7 +217,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
"emailAddresses": {
"main": "patched@example.org"
},
- "postalAddress": "Patched Address",
+ "postalAddress": {
+ "extra": "Extra Property",
+ "co": "P. Patcher",
+ "street": "Patchstraße 5"
+ },
"phoneNumbers": {
"phone_office": "+01 100 123456"
}
@@ -229,7 +236,10 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("uuid", isUuidValid())
.body("caption", is("Temp patched contact"))
.body("emailAddresses", is(Map.of("main", "patched@example.org")))
- .body("postalAddress", is("Patched Address"))
+ .body("postalAddress", hasEntry("name", givenContact.getPostalAddress().get("name"))) // unchanged
+ .body("postalAddress", hasEntry("extra", "Extra Property")) // unchanged
+ .body("postalAddress", hasEntry("co", "P. Patcher")) // patched
+ .body("postalAddress", hasEntry("street", "Patchstraße 5")) // patched
.body("phoneNumbers", is(Map.of("phone_office", "+01 100 123456")));
// @formatter:on
@@ -239,7 +249,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.matches(person -> {
assertThat(person.getCaption()).isEqualTo("Temp patched contact");
assertThat(person.getEmailAddresses()).containsExactlyEntriesOf(Map.of("main", "patched@example.org"));
- assertThat(person.getPostalAddress()).isEqualTo("Patched Address");
+ assertThat(person.getPostalAddress()).containsAllEntriesOf(Map.ofEntries(
+ entry("name", givenContact.getPostalAddress().get("name")),
+ entry("co", "P. Patcher"),
+ entry("street", "Patchstraße 5")
+ ));
assertThat(person.getPhoneNumbers()).containsExactlyEntriesOf(Map.of("phone_office", "+01 100 123456"));
return true;
});
@@ -264,7 +278,7 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
"phone_office": "+01 100 123456"
}
}
- """)
+ """)
.port(port)
.when()
.patch("http://localhost/api/hs/office/contacts/" + givenContact.getUuid())
@@ -274,7 +288,6 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("uuid", isUuidValid())
.body("caption", is(givenContact.getCaption()))
.body("emailAddresses", is(Map.of("main", "patched@example.org")))
- .body("postalAddress", is(givenContact.getPostalAddress()))
.body("phoneNumbers", is(Map.of("phone_office", "+01 100 123456")));
// @formatter:on
@@ -283,12 +296,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.matches(person -> {
assertThat(person.getCaption()).isEqualTo(givenContact.getCaption());
assertThat(person.getEmailAddresses()).containsExactlyEntriesOf(Map.of("main", "patched@example.org"));
- assertThat(person.getPostalAddress()).isEqualTo(givenContact.getPostalAddress());
+ assertThat(person.getPostalAddress()).containsExactlyInAnyOrderEntriesOf(givenContact.getPostalAddress());
assertThat(person.getPhoneNumbers()).containsExactlyEntriesOf(Map.of("phone_office", "+01 100 123456"));
return true;
});
}
-
}
@Nested
@@ -361,8 +373,13 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
final var newContact = HsOfficeContactRbacEntity.builder()
.uuid(UUID.randomUUID())
.caption("Temp from " + Context.getCallerMethodNameFromStackFrame(1) )
+ .postalAddress(Map.ofEntries(
+ entry("name", RandomStringUtils.randomAlphabetic(6) + " " + RandomStringUtils.randomAlphabetic(10)),
+ entry("street", RandomStringUtils.randomAlphabetic(10) + randomInt(1, 99)),
+ entry("zipcode", "D-" + randomInt(10000, 99999)),
+ entry("city", RandomStringUtils.randomAlphabetic(10))
+ ))
.emailAddresses(Map.of("main", RandomStringUtils.randomAlphabetic(10) + "@example.org"))
- .postalAddress("Postal Address " + RandomStringUtils.randomAlphabetic(10))
.phoneNumbers(Map.of("phone_office", "+01 200 " + RandomStringUtils.randomNumeric(8)))
.build();
@@ -378,4 +395,8 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
em.createQuery("DELETE FROM HsOfficeContactRbacEntity c WHERE c.caption LIKE 'Temp %'").executeUpdate();
}).assertSuccessful();
}
+
+ private int randomInt(final int min, final int max) {
+ return ThreadLocalRandom.current().nextInt(min, max);
+ }
}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactPatcherUnitTest.java
index 95b4eb94..e11a6a61 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactPatcherUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactPatcherUnitTest.java
@@ -19,6 +19,19 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
> {
private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID();
+
+ private static final Map PATCH_POSTAL_ADDRESS = patchMap(
+ entry("name", "Patty Patch"),
+ entry("street", "Patchstreet 10"),
+ entry("zipcode", null),
+ entry("city", "Hamburg")
+ );
+ private static final Map PATCHED_POSTAL_ADDRESS = patchMap(
+ entry("name", "Patty Patch"),
+ entry("street", "Patchstreet 10"),
+ entry("city", "Hamburg")
+ );
+
private static final Map PATCH_EMAIL_ADDRESSES = patchMap(
entry("main", "patched@example.com"),
entry("paul", null),
@@ -46,6 +59,11 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
final var entity = new HsOfficeContactRbacEntity();
entity.setUuid(INITIAL_CONTACT_UUID);
entity.setCaption("initial caption");
+ entity.putPostalAddress(Map.ofEntries(
+ entry("name", "Ina Initial"),
+ entry("street", "Initialstraße 50"),
+ entry("zipcode", "20000"),
+ entry("city", "Hamburg")));
entity.putEmailAddresses(Map.ofEntries(
entry("main", "initial@example.org"),
entry("paul", "paul@example.com"),
@@ -54,7 +72,6 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
entry("phone_office", "+49 40 12345-00"),
entry("phone_mobile", "+49 1555 1234567"),
entry("fax", "+49 40 12345-90")));
- entity.setPostalAddress("Initialstraße 50\n20000 Hamburg");
return entity;
}
@@ -77,24 +94,26 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
"patched caption",
HsOfficeContactRbacEntity::setCaption),
new SimpleProperty<>(
- "resources",
+ "postalAddress",
+ HsOfficeContactPatchResource::setPostalAddress,
+ PATCH_POSTAL_ADDRESS,
+ HsOfficeContactRbacEntity::putPostalAddress,
+ PATCHED_POSTAL_ADDRESS)
+ .notNullable(),
+ new SimpleProperty<>(
+ "emailAddresses",
HsOfficeContactPatchResource::setEmailAddresses,
PATCH_EMAIL_ADDRESSES,
HsOfficeContactRbacEntity::putEmailAddresses,
PATCHED_EMAIL_ADDRESSES)
.notNullable(),
new SimpleProperty<>(
- "resources",
+ "phoneNumbers",
HsOfficeContactPatchResource::setPhoneNumbers,
PATCH_PHONE_NUMBERS,
HsOfficeContactRbacEntity::putPhoneNumbers,
PATCHED_PHONE_NUMBERS)
- .notNullable(),
- new JsonNullableProperty<>(
- "patched given name",
- HsOfficeContactPatchResource::setPostalAddress,
- "patched given name",
- HsOfficeContactRbacEntity::setPostalAddress)
+ .notNullable()
);
}
}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacTestEntity.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacTestEntity.java
index ba96f31b..5589cab2 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacTestEntity.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacTestEntity.java
@@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.hs.office.contact;
import java.util.Map;
+import static java.util.Map.entry;
+
public class HsOfficeContactRbacTestEntity {
public static final HsOfficeContactRbacEntity TEST_RBAC_CONTACT = hsOfficeContact("some contact", "some-contact@example.com");
@@ -9,7 +11,12 @@ public class HsOfficeContactRbacTestEntity {
static public HsOfficeContactRbacEntity hsOfficeContact(final String caption, final String emailAddr) {
return HsOfficeContactRbacEntity.builder()
.caption(caption)
- .postalAddress("address of " + caption)
+ .postalAddress(Map.ofEntries(
+ entry("name", "M. Meyer"),
+ entry("street", "Teststraße 11"),
+ entry("zipcode", "D-12345"),
+ entry("city", "Berlin")
+ ))
.emailAddresses(Map.of("main", emailAddr))
.build();
}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealTestEntity.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealTestEntity.java
index d8cdfe1b..c10a9a35 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealTestEntity.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRealTestEntity.java
@@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.hs.office.contact;
import java.util.Map;
+import static java.util.Map.entry;
+
public class HsOfficeContactRealTestEntity {
public static final HsOfficeContactRealEntity TEST_REAL_CONTACT = hsOfficeContact("some contact", "some-contact@example.com");
@@ -9,7 +11,12 @@ public class HsOfficeContactRealTestEntity {
static public HsOfficeContactRealEntity hsOfficeContact(final String caption, final String emailAddr) {
return HsOfficeContactRealEntity.builder()
.caption(caption)
- .postalAddress("address of " + caption)
+ .postalAddress(Map.ofEntries(
+ entry("name", "M. Meyer"),
+ entry("street", "Teststraße 11"),
+ entry("zipcode", "D-12345"),
+ entry("city", "Berlin")
+ ))
.emailAddresses(Map.of("main", emailAddr))
.build();
}
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..a15ffbce 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"
@@ -463,13 +460,16 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
"type": "DEBITOR",
"contact": {
"caption": "first contact",
- "postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
- "emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
- "phoneNumbers": { "phone_office": "+49 123 1234567" }
+ "postalAddress": {
+ "country": "Germany"
+ },
+ "emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
+ "phoneNumbers": { "phone_office": "+49 123 1234567" }
+ }
}
},
"debitorNumber": 1000111,
- "debitorNumberSuffix": 11,
+ "debitorNumberSuffix": "11",
"partner": {
"partnerNumber": 10001,
"partnerRel": {
@@ -479,10 +479,11 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
"mark": null,
"contact": {
"caption": "first contact",
- "postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
- "emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
- "phoneNumbers": { "phone_office": "+49 123 1234567" }
- }
+ "postalAddress": {
+ "country": "Germany"
+ },
+ "emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
+ "phoneNumbers": { "phone_office": "+49 123 1234567" }
},
"details": {
"registrationOffice": "Hamburg",
@@ -581,7 +582,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/relation/HsOfficeRelationControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java
index e767ff1c..2dac741b 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationControllerAcceptanceTest.java
@@ -199,7 +199,9 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
"type": "REPRESENTATIVE",
"contact": {
"caption": "first contact",
- "postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
+ "postalAddress": {
+ "country": "Germany"
+ },
"emailAddresses": {
"main": "contact-admin@firstcontact.example.com"
},
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..0288ab9f
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java
@@ -0,0 +1,339 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import net.hostsharing.hsadminng.HsadminNgApplication;
+import net.hostsharing.hsadminng.hs.office.scenarios.contact.AddPhoneNumberToContactData;
+import net.hostsharing.hsadminng.hs.office.scenarios.contact.AmendContactData;
+import net.hostsharing.hsadminng.hs.office.scenarios.contact.RemovePhoneNumberFromContactData;
+import net.hostsharing.hsadminng.hs.office.scenarios.contact.ReplaceContactData;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateExternalDebitorForPartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSelfDebitorForPartner;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSepaMandateForDebitor;
+import net.hostsharing.hsadminng.hs.office.scenarios.debitor.FinallyDeleteSepaMandateForDebitor;
+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 - Hamburg"})
+ void shouldCreateLegalPersonAsPartner() {
+ new CreatePartner(this)
+ .given("partnerNumber", 31010)
+ .given("personType", "LEGAL_PERSON")
+ .given("tradeName", "Test AG")
+ .given("contactCaption", "Test AG - Hamburg")
+ .given("postalAddress", """
+ "firm": "Test AG",
+ "street": "Shanghai-Allee 1",
+ "zipcode": "20123",
+ "city": "Hamburg",
+ "country": "Germany"
+ """)
+ .given("officePhoneNumber", "+49 40 654321-0")
+ .given("emailAddress", "hamburg@test-ag.example.org")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(1011)
+ @Produces(explicitly = "Partner: Michelle Matthieu", implicitly = {"Person: Michelle Matthieu", "Contact: Michelle Matthieu"})
+ void shouldCreateNaturalPersonAsPartner() {
+ new CreatePartner(this)
+ .given("partnerNumber", 31011)
+ .given("personType", "NATURAL_PERSON")
+ .given("givenName", "Michelle")
+ .given("familyName", "Matthieu")
+ .given("contactCaption", "Michelle Matthieu")
+ .given("postalAddress", """
+ "name": "Michelle Matthieu",
+ "street": "An der Wandse 34",
+ "zipcode": "22123",
+ "city": "Hamburg",
+ "country": "Germany"
+ """)
+ .given("officePhoneNumber", "+49 40 123456")
+ .given("emailAddress", "michelle.matthieu@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", """
+ "name": "Michelle Matthieu",
+ "street": "An der Alster 100",
+ "zipcode": "20000",
+ "city": "Hamburg",
+ "country": "Germany"
+ """)
+ .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(1100)
+ @Requires("Partner: Michelle Matthieu")
+ void shouldAmendContactData() {
+ new AmendContactData(this)
+ .given("partnerName", "Matthieu")
+ .given("newEmailAddress", "michelle@matthieu.example.org")
+ .doRun();
+ }
+
+ @Test
+ @Order(1101)
+ @Requires("Partner: Michelle Matthieu")
+ void shouldAddPhoneNumberToContactData() {
+ new AddPhoneNumberToContactData(this)
+ .given("partnerName", "Matthieu")
+ .given("phoneNumberKeyToAdd", "mobile")
+ .given("phoneNumberToAdd", "+49 152 1234567")
+ .doRun();
+ }
+
+ @Test
+ @Order(1102)
+ @Requires("Partner: Michelle Matthieu")
+ void shouldRemovePhoneNumberFromContactData() {
+ new RemovePhoneNumberFromContactData(this)
+ .given("partnerName", "Matthieu")
+ .given("phoneNumberKeyToRemove", "office")
+ .doRun();
+ }
+
+ @Test
+ @Order(1103)
+ @Requires("Partner: Test AG")
+ void shouldReplaceContactData() {
+ new ReplaceContactData(this)
+ .given("partnerName", "Test AG")
+ .given("newContactCaption", "Test AG - China")
+ .given("newPostalAddress", """
+ "firm": "Test AG",
+ "name": "Fi Zhong-Kha",
+ "building": "Thi Chi Koh Building",
+ "street": "No.2 Commercial Second Street",
+ "district": "Niushan Wei Wu",
+ "city": "Dongguan City",
+ "province": "Guangdong Province",
+ "country": "China"
+ """)
+ .given("newOfficePhoneNumber", "++15 999 654321" )
+ .given("newEmailAddress", "norden@test-ag.example.org")
+ .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)
+ // existing debitor
+ .given("debitorNumber", "3101000")
+
+ // new sepa-mandate
+ .given("mandateReference", "Test AG - main debitor")
+ .given("mandateAgreement", "2022-10-12")
+ .given("mandateValidFrom", "2024-10-15")
+
+ // new bank-account
+ .given("bankAccountHolder", "Test AG - debit bank account")
+ .given("bankAccountIBAN", "DE02701500000000594937")
+ .given("bankAccountBIC", "SSKMDEMM")
+ .doRun()
+ .keep();
+ }
+
+ @Test
+ @Order(3108)
+ @Requires("SEPA-Mandate: Test AG")
+ void shouldInvalidateSepaMandateForDebitor() {
+ new InvalidateSepaMandateForDebitor(this)
+ .given("bankAccountIBAN", "DE02701500000000594937")
+ .given("mandateValidUntil", "2025-09-30")
+ .doRun();
+ }
+
+ @Test
+ @Order(3109)
+ @Requires("SEPA-Mandate: Test AG")
+ void shouldFinallyDeleteSepaMandateForDebitor() {
+ new FinallyDeleteSepaMandateForDebitor(this)
+ .given("bankAccountIBAN", "DE02701500000000594937")
+ .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/PathAssertion.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/PathAssertion.java
new file mode 100644
index 00000000..ffd9df8e
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/PathAssertion.java
@@ -0,0 +1,23 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase.HttpResponse;
+
+import java.util.function.Consumer;
+
+public class PathAssertion {
+
+ private final String path;
+
+ public PathAssertion(final String path) {
+ this.path = path;
+ }
+
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ public Consumer contains(final String resolvableValue) {
+ return response -> response.path(path).contains(ScenarioTest.resolve(resolvableValue));
+ }
+
+ public Consumer doesNotExist() {
+ return response -> response.path(path).isNull(); // here, null Optional means key not found in JSON
+ }
+}
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..e8384ba2
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/README.md
@@ -0,0 +1,96 @@
+# 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 is implemented by the `run()`-method which contains HTTP-calls.
+
+Each HTTP-call is wrapped into either `obtain(...)` to keep the result in a placeholder variable,
+the variable name is also used as a title.
+Or it's wrapped into a `withTitle(...)` to assign a title.
+
+The HTTP-call is followed by some assertions, e.g. the HTTP status and JSON-path-expression-matchers.
+
+Use `${...}` for placeholders which need to be replaced with JSON quotes
+(e.g. strings are quoted, numbers are not),
+`%{...}` for placeholders which need to be rendered raw
+and `&{...}` for placeholders which need to get URI-encoded.
+
+If `???` is added before the closing brace, the property is optional.
+This means, if it's not available in the properties, `null` is used.
+
+Properties with null-values are removed from the JSON.
+If you need to keep a null-value, e.g. to delete a property,
+use `NULL` (all caps) in the template (not the variable value).
+
+A special syntax is the infix `???`-operator like in: `${%{var1???}???%{var2???}%{var3???}}`.
+In this case the first non-null value is used.
+
+
+
+### The Use-Case Verification
+
+The verification-step is implemented by the `verify()`-method which usually contains a HTTP-HTTP-call.
+
+It can also contain a JSON-path verification to check if a certain value is in the result.
+
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..4600584a
--- /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 org.testcontainers.shaded.org.apache.commons.lang3.ObjectUtils;
+
+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 ObjectUtils.toString(uuid);
+ }
+ }
+
+ 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();
+ 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..aaa8855a
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TemplateResolver.java
@@ -0,0 +1,200 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+public class TemplateResolver {
+
+ private final static Pattern pattern = Pattern.compile(",(\\s*})", Pattern.MULTILINE);
+ private static final String IF_NOT_FOUND_SYMBOL = "???";
+
+ enum PlaceholderPrefix {
+ RAW('%') {
+ @Override
+ String convert(final Object value) {
+ return value != null ? value.toString() : "";
+ }
+ },
+ JSON_QUOTED('$'){
+ @Override
+ String convert(final Object value) {
+ return jsonQuoted(value);
+ }
+ },
+ URI_ENCODED('&'){
+ @Override
+ String convert(final Object value) {
+ return value != null ? URLEncoder.encode(value.toString(), StandardCharsets.UTF_8) : "";
+ }
+ };
+
+ private final char prefixChar;
+
+ PlaceholderPrefix(final char prefixChar) {
+ this.prefixChar = prefixChar;
+ }
+
+ static boolean contains(final char givenChar) {
+ return Arrays.stream(values()).anyMatch(p -> p.prefixChar == givenChar);
+ }
+
+ static PlaceholderPrefix ofPrefixChar(final char givenChar) {
+ return Arrays.stream(values()).filter(p -> p.prefixChar == givenChar).findFirst().orElseThrow();
+ }
+
+ abstract String convert(final Object value);
+ }
+
+ 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() {
+ final var resolved = copy();
+ final var withoutDroppedLines = dropLinesWithNullProperties(resolved);
+ final var result = removeDanglingCommas(withoutDroppedLines);
+ return result;
+ }
+
+ private static String removeDanglingCommas(final String withoutDroppedLines) {
+ return pattern.matcher(withoutDroppedLines).replaceAll("$1");
+ }
+
+ private String dropLinesWithNullProperties(final String text) {
+ return Arrays.stream(text.split("\n"))
+ .filter(TemplateResolver::keepLine)
+ .map(TemplateResolver::keptNullValues)
+ .collect(Collectors.joining("\n"));
+ }
+
+ private static boolean keepLine(final String line) {
+ final var trimmed = line.trim();
+ return !trimmed.endsWith("null,") && !trimmed.endsWith("null");
+ }
+
+ private static String keptNullValues(final String line) {
+ return line.replace(": NULL", ": null");
+ }
+
+ private String copy() {
+ while (hasMoreChars()) {
+ if (PlaceholderPrefix.contains(currentChar()) && nextChar() == '{') {
+ startPlaceholder(currentChar());
+ } else {
+ resolved.append(fetchChar());
+ }
+ }
+ return resolved.toString();
+ }
+
+ 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 (PlaceholderPrefix.contains (currentChar()) && nextChar() == '{') {
+ ++nested;
+ placeholder.append(fetchChar());
+ } else {
+ placeholder.append(fetchChar());
+ }
+ }
+ final var name = new TemplateResolver(placeholder.toString(), properties).resolve();
+ final var value = propVal(name);
+ resolved.append(
+ PlaceholderPrefix.ofPrefixChar(intro).convert(value)
+ );
+ skipChar('}');
+ }
+
+ private Object propVal(final String nameExpression) {
+ if (nameExpression.endsWith(IF_NOT_FOUND_SYMBOL)) {
+ final String pureName = nameExpression.substring(0, nameExpression.length() - IF_NOT_FOUND_SYMBOL.length());
+ return properties.get(pureName);
+ } else if (nameExpression.contains(IF_NOT_FOUND_SYMBOL)) {
+ final var parts = StringUtils.split(nameExpression, IF_NOT_FOUND_SYMBOL);
+ return Arrays.stream(parts).filter(Objects::nonNull).findFirst().orElseGet(() -> {
+ if ( parts[parts.length-1].isEmpty() ) {
+ // => whole expression ends with IF_NOT_FOUND_SYMBOL, thus last null element was optional
+ return null;
+ }
+ // => last alternative element in expression was null and not optional
+ throw new IllegalStateException("Missing required value in property-chain: " + nameExpression);
+ });
+ } else {
+ final var val = properties.get(nameExpression);
+ if (val == null) {
+ throw new IllegalStateException("Missing required property: " + nameExpression);
+ }
+ 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 jsonQuoted(final Object value) {
+ return switch (value) {
+ case null -> null;
+ case Boolean bool -> bool.toString();
+ case Number number -> number.toString();
+ case String string -> "\"" + string.replace("\n", "\\n") + "\"";
+ default -> "\"" + value + "\"";
+ };
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TemplateResolverUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TemplateResolverUnitTest.java
new file mode 100644
index 00000000..2d63e204
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TemplateResolverUnitTest.java
@@ -0,0 +1,73 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TemplateResolverUnitTest {
+
+ @Test
+ void resolveTemplate() {
+ final var resolved = new TemplateResolver("""
+ with optional JSON quotes:
+
+ ${boolean},
+ ${numeric},
+ ${simple placeholder},
+ ${nested %{name}},
+ ${with-special-chars}
+
+ and without quotes:
+
+ %{boolean},
+ %{numeric},
+ %{simple placeholder},
+ %{nested %{name}},
+ %{with-special-chars}
+
+ and uri-encoded:
+
+ &{boolean},
+ &{numeric},
+ &{simple placeholder},
+ &{nested %{name}},
+ &{with-special-chars}
+ """,
+ Map.ofEntries(
+ Map.entry("name", "placeholder"),
+ Map.entry("boolean", true),
+ Map.entry("numeric", 42),
+ Map.entry("simple placeholder", "einfach"),
+ Map.entry("nested placeholder", "verschachtelt"),
+ Map.entry("with-special-chars", "3&3 AG")
+ )).resolve();
+
+ assertThat(resolved).isEqualTo("""
+ with optional JSON quotes:
+
+ true,
+ 42,
+ "einfach",
+ "verschachtelt",
+ "3&3 AG"
+
+ and without quotes:
+
+ true,
+ 42,
+ einfach,
+ verschachtelt,
+ 3&3 AG
+
+ and uri-encoded:
+
+ true,
+ 42,
+ einfach,
+ verschachtelt,
+ 3%263+AG
+ """.trim());
+ }
+}
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..8aba4319
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/TestReport.java
@@ -0,0 +1,92 @@
+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() {
+ if (markdownReport != null) {
+ 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..77aa7b93
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCase.java
@@ -0,0 +1,369 @@
+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.assertj.core.api.OptionalAssert;
+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.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Consumer;
+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.jupiter.api.Assertions.fail;
+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();
+ }
+ })
+ );
+ final var response = run();
+ verify();
+ return response;
+ }
+
+ protected abstract HttpResponse run();
+
+ protected void verify() {
+ }
+
+ 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), () -> {
+ final var response = http.get().keep(extractor);
+ Arrays.stream(extraInfo).forEach(testReport::printPara);
+ return response;
+ });
+ }
+
+ public final void obtain(final String alias, final Supplier http, final String... extraInfo) {
+ withTitle(ScenarioTest.resolve(alias), () -> {
+ final var response = http.get().keep();
+ Arrays.stream(extraInfo).forEach(testReport::printPara);
+ return response;
+ });
+ }
+
+ public HttpResponse withTitle(final String title, final Supplier code) {
+ this.nextTitle = title;
+ final var response = code.get();
+ this.nextTitle = null;
+ return response;
+ }
+
+ @SneakyThrows
+ public final HttpResponse httpGet(final String uriPathWithPlaceholders) {
+ final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
+ 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 uriPathWithPlaceholders, final JsonTemplate bodyJsonTemplate) {
+ final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
+ 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 uriPathWithPlaceholders, final JsonTemplate bodyJsonTemplate) {
+ final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
+ 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 uriPathWithPlaceholders) {
+ final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
+ 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);
+ }
+
+ protected PathAssertion path(final String path) {
+ return new PathAssertion(path);
+ }
+
+ protected void verify(
+ final String title,
+ final Supplier http,
+ final Consumer... assertions) {
+ withTitle(ScenarioTest.resolve(title), () -> {
+ final var response = http.get();
+ Arrays.stream(assertions).forEach(assertion -> assertion.accept(response));
+ return response;
+ });
+ }
+
+ public final UUID uuid(final String alias) {
+ return ScenarioTest.uuid(alias);
+ }
+
+ public String uriEncoded(final String text) {
+ return encode(ScenarioTest.resolve(text), StandardCharsets.UTF_8);
+ }
+
+ public static class JsonTemplate {
+
+ private final String template;
+
+ private JsonTemplate(final String jsonTemplate) {
+ this.template = jsonTemplate;
+ }
+
+ String resolvePlaceholders() {
+ return ScenarioTest.resolve(template);
+ }
+ }
+
+ public final 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 HttpResponse 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)));
+ return this;
+ }
+
+ public HttpResponse 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));
+ return this;
+ }
+
+ @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(ScenarioTest.resolve(path));
+ }
+
+ @SneakyThrows
+ public Optional getFromBodyAsOptional(final String path) {
+ try {
+ return Optional.ofNullable(JsonPath.parse(response.body()).read(ScenarioTest.resolve(path)));
+ } catch (final Exception e) {
+ return null; // means the property did not exist at all, not that it was there with value null
+ }
+ }
+
+ @SneakyThrows
+ public OptionalAssert path(final String path) {
+ return assertThat(getFromBodyAsOptional(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");
+ } else {
+ fail("please wrap the http...-call in the UseCase using `withTitle(...)`");
+ }
+
+ // 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/UseCaseNotImplementedYet.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCaseNotImplementedYet.java
new file mode 100644
index 00000000..d21d6413
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/UseCaseNotImplementedYet.java
@@ -0,0 +1,16 @@
+package net.hostsharing.hsadminng.hs.office.scenarios;
+
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+public class UseCaseNotImplementedYet extends UseCase {
+
+ public UseCaseNotImplementedYet(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+ assumeThat(false).isTrue(); // makes the test gray
+ return null;
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/AddPhoneNumberToContactData.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/AddPhoneNumberToContactData.java
new file mode 100644
index 00000000..3e837195
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/AddPhoneNumberToContactData.java
@@ -0,0 +1,51 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.contact;
+
+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.OK;
+
+public class AddPhoneNumberToContactData extends UseCase {
+
+ public AddPhoneNumberToContactData(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain(
+ "partnerContactUuid",
+ () -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].contact.uuid"),
+ "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ withTitle("Patch the Additional Phone-Number into the Contact", () ->
+ httpPatch("/api/hs/office/contacts/%{partnerContactUuid}", usingJsonBody("""
+ {
+ "phoneNumbers": {
+ ${phoneNumberKeyToAdd}: ${phoneNumberToAdd}
+ }
+ }
+ """))
+ .expecting(HttpStatus.OK)
+ );
+
+ return null;
+ }
+
+ @Override
+ protected void verify() {
+ verify(
+ "Verify if the New Phone Number Got Added",
+ () -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
+ .expecting(OK).expecting(JSON).expectArrayElements(1),
+ path("[0].contact.phoneNumbers.%{phoneNumberKeyToAdd}").contains("%{phoneNumberToAdd}")
+ );
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/AmendContactData.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/AmendContactData.java
new file mode 100644
index 00000000..0c0e3021
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/AmendContactData.java
@@ -0,0 +1,46 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.contact;
+
+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.OK;
+
+public class AmendContactData extends UseCase {
+
+ public AmendContactData(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("partnerContactUuid", () ->
+ httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].contact.uuid"),
+ "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ withTitle("Patch the New Phone Number Into the Contact", () ->
+ httpPatch("/api/hs/office/contacts/%{partnerContactUuid}", usingJsonBody("""
+ {
+ "caption": ${newContactCaption???},
+ "postalAddress": {
+ %{newPostalAddress???}
+ },
+ "phoneNumbers": {
+ "office": ${newOfficePhoneNumber???}
+ },
+ "emailAddresses": {
+ "main": ${newEmailAddress???}
+ }
+ }
+ """))
+ .expecting(HttpStatus.OK)
+ );
+
+ return null;
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/RemovePhoneNumberFromContactData.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/RemovePhoneNumberFromContactData.java
new file mode 100644
index 00000000..c499ee71
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/RemovePhoneNumberFromContactData.java
@@ -0,0 +1,50 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.contact;
+
+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.OK;
+
+public class RemovePhoneNumberFromContactData extends UseCase {
+
+ public RemovePhoneNumberFromContactData(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain(
+ "partnerContactUuid",
+ () -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].contact.uuid"),
+ "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
+ );
+
+ withTitle("Patch the Additional Phone-Number into the Contact", () ->
+ httpPatch("/api/hs/office/contacts/%{partnerContactUuid}", usingJsonBody("""
+ {
+ "phoneNumbers": {
+ ${phoneNumberKeyToRemove}: NULL
+ }
+ }
+ """))
+ .expecting(HttpStatus.OK)
+ );
+
+ return null;
+ }
+
+ @Override
+ protected void verify() {
+ verify(
+ "Verify if the New Phone Number Got Added",
+ () -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
+ .expecting(OK).expecting(JSON).expectArrayElements(1),
+ path("[0].contact.phoneNumbers.%{phoneNumberKeyToRemove}").doesNotExist()
+ );
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/ReplaceContactData.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/ReplaceContactData.java
new file mode 100644
index 00000000..f3e88269
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/contact/ReplaceContactData.java
@@ -0,0 +1,68 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.contact;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.CREATED;
+import static org.springframework.http.HttpStatus.OK;
+
+public class ReplaceContactData extends UseCase {
+
+ public ReplaceContactData(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("partnerRelationUuid", () ->
+ httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
+ .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("Contact: %{newContactCaption}", () ->
+ httpPost("/api/hs/office/contacts", usingJsonBody("""
+ {
+ "caption": ${newContactCaption},
+ "postalAddress": {
+ %{newPostalAddress???}
+ },
+ "phoneNumbers": {
+ "phone": ${newOfficePhoneNumber???}
+ },
+ "emailAddresses": {
+ "main": ${newEmailAddress???}
+ }
+ }
+ """))
+ .expecting(CREATED).expecting(JSON),
+ "Please check first if that contact already exists, if so, use it's UUID below.",
+ "If any `postalAddress` sub-properties besides those specified in the API " +
+ "(currently `firm`, `name`, `co`, `street`, `zipcode`, `city`, `country`) " +
+ "its values might not appear in external systems.");
+
+ withTitle("Replace the Contact-Reference in the Partner-Relation", () ->
+ httpPatch("/api/hs/office/relations/%{partnerRelationUuid}", usingJsonBody("""
+ {
+ "contactUuid": ${Contact: %{newContactCaption}}
+ }
+ """))
+ .expecting(OK)
+ );
+
+ return null;
+ }
+
+ @Override
+ protected void verify() {
+ verify(
+ "Verify if the Contact-Relation Got Replaced in the Partner-Relation",
+ () -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
+ .expecting(OK).expecting(JSON).expectArrayElements(1),
+ path("[0].contact.caption").contains("%{newContactCaption}")
+ );
+ }
+}
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..194d4513
--- /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..fc16adb3
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateSelfDebitorForPartner.java
@@ -0,0 +1,68 @@
+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."
+ );
+
+ 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..606e2320
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/CreateSepaMandateForDebitor.java
@@ -0,0 +1,47 @@
+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 CreateSepaMandateForDebitor extends UseCase {
+
+ public CreateSepaMandateForDebitor(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("Debitor: Test AG - main debitor", () ->
+ httpGet("/api/hs/office/debitors?debitorNumber=&{debitorNumber}")
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid")
+ );
+
+ obtain("BankAccount: Test AG - debit bank account", () ->
+ httpPost("/api/hs/office/bankaccounts", usingJsonBody("""
+ {
+ "holder": ${bankAccountHolder},
+ "iban": ${bankAccountIBAN},
+ "bic": ${bankAccountBIC}
+ }
+ """))
+ .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": ${mandateReference},
+ "agreement": ${mandateAgreement},
+ "validFrom": ${mandateValidFrom}
+ }
+ """))
+ .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..19a0f159
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteDebitor.java
@@ -0,0 +1,33 @@
+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() {
+ withTitle("Delete the Debitor using its UUID", () ->
+ httpDelete("/api/hs/office/debitors/&{Debitor: Test AG - delete debitor}")
+ .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..e5459c88
--- /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/&{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/FinallyDeleteSepaMandateForDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/FinallyDeleteSepaMandateForDebitor.java
new file mode 100644
index 00000000..224a98f3
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/FinallyDeleteSepaMandateForDebitor.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;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.OK;
+
+public class FinallyDeleteSepaMandateForDebitor extends UseCase {
+
+ public FinallyDeleteSepaMandateForDebitor(final ScenarioTest testSuite) {
+ super(testSuite);
+ }
+
+ @Override
+ protected HttpResponse run() {
+
+ obtain("SEPA-Mandate: %{bankAccountIBAN}", () ->
+ httpGet("/api/hs/office/sepamandates?iban=&{bankAccountIBAN}")
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "With production data, the bank-account could be used in multiple SEPA-mandates, make sure to use the right one!"
+ );
+
+ // TODO.spec: When to allow actual deletion of SEPA-mandates? Add constraint accordingly.
+ return withTitle("Delete the SEPA-Mandate by its UUID", () -> httpDelete("/api/hs/office/sepamandates/&{SEPA-Mandate: %{bankAccountIBAN}}")
+ .expecting(HttpStatus.NO_CONTENT)
+ );
+ }
+}
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..3af160c7
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/InvalidateSepaMandateForDebitor.java
@@ -0,0 +1,34 @@
+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() {
+
+ obtain("SEPA-Mandate: %{bankAccountIBAN}", () ->
+ httpGet("/api/hs/office/sepamandates?iban=&{bankAccountIBAN}")
+ .expecting(OK).expecting(JSON),
+ response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
+ "With production data, the bank-account could be used in multiple SEPA-mandates, make sure to use the right one!"
+ );
+
+ return withTitle("Patch the End of the Mandate into the SEPA-Mandate", () ->
+ httpPatch("/api/hs/office/sepamandates/&{SEPA-Mandate: %{bankAccountIBAN}}", usingJsonBody("""
+ {
+ "validUntil": ${mandateValidUntil}
+ }
+ """))
+ .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..6c1fd1dd
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/AddOperationsContactToPartner.java
@@ -0,0 +1,78 @@
+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);
+ }
+
+ @Override
+ protected void verify() {
+ verify(
+ "Verify the New OPERATIONS Relation",
+ () -> httpGet("/api/hs/office/relations?relationType=OPERATIONS&personData=" + uriEncoded(
+ "%{operationsContactFamilyName}"))
+ .expecting(OK).expecting(JSON).expectArrayElements(1),
+ path("[0].contact.caption").contains("%{operationsContactGivenName} %{operationsContactFamilyName}")
+ );
+ }
+}
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..c5381684
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/AddRepresentativeToPartner.java
@@ -0,0 +1,80 @@
+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);
+ }
+
+ @Override
+ protected void verify() {
+ verify(
+ "Verify the REPRESENTATIVE Relation Got Removed",
+ () -> httpGet("/api/hs/office/relations?relationType=REPRESENTATIVE&personData=" + uriEncoded("%{representativeFamilyName}"))
+ .expecting(OK).expecting(JSON).expectArrayElements(1),
+ path("[0].contact.caption").contains("%{representativeGivenName} %{representativeFamilyName}")
+ );
+ }
+}
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..9538fdbf
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/CreatePartner.java
@@ -0,0 +1,86 @@
+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???}???%{givenName???} %{familyName???}}", () ->
+ httpPost("/api/hs/office/persons", usingJsonBody("""
+ {
+ "personType": ${personType???},
+ "tradeName": ${tradeName???},
+ "givenName": ${givenName???},
+ "familyName": ${familyName???}
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
+ );
+
+ obtain("Contact: %{contactCaption}", () ->
+ httpPost("/api/hs/office/contacts", usingJsonBody("""
+ {
+ "caption": ${contactCaption},
+ "postalAddress": {
+ %{postalAddress???}
+ },
+ "phoneNumbers": {
+ "office": ${officePhoneNumber???}
+ },
+ "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???}???%{givenName???} %{familyName???}}},
+ "contactUuid": ${Contact: %{contactCaption}}
+ },
+ "details": {
+ "registrationOffice": "Registergericht Hamburg",
+ "registrationNumber": "1234567"
+ }
+ }
+ """))
+ .expecting(HttpStatus.CREATED).expecting(ContentType.JSON);
+ }
+
+ @Override
+ protected void verify() {
+ verify(
+ "Verify the New Partner Relation",
+ () -> httpGet("/api/hs/office/relations?relationType=PARTNER&contactData=&{contactCaption}")
+ .expecting(OK).expecting(JSON).expectArrayElements(1)
+ );
+ }
+}
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..4453d959
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/partner/DeletePartner.java
@@ -0,0 +1,27 @@
+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() {
+ withTitle("Delete Partner by its UUID", () ->
+ httpDelete("/api/hs/office/partners/&{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..4a43503c
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/person/CreatePerson.java
@@ -0,0 +1,27 @@
+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 withTitle("Create the Person", () ->
+ 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..c30019e7
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/RemoveOperationsContactFromPartner.java
@@ -0,0 +1,43 @@
+package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
+
+import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
+import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
+
+import static io.restassured.http.ContentType.JSON;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+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 withTitle("Delete the Contact", () ->
+ httpDelete("/api/hs/office/relations/&{Operations-Contact: %{operationsContactPerson}}")
+ .expecting(NO_CONTENT)
+ );
+ }
+
+ @Override
+ protected void verify() {
+ verify(
+ "Verify the New OPERATIONS Relation",
+ () -> httpGet("/api/hs/office/relations/&{Operations-Contact: %{operationsContactPerson}}")
+ .expecting(NOT_FOUND)
+ );
+ }
+}
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..3e4ae74b
--- /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..dfb17ea1
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/subscription/UnsubscribeFromMailinglist.java
@@ -0,0 +1,33 @@
+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 withTitle("Delete the Subscriber-Relation by its UUID", () ->
+ httpDelete("/api/hs/office/relations/&{Subscription: %{subscriberEMailAddress}}")
+ .expecting(NO_CONTENT)
+ );
+ }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java
index 89b25f35..48ed0d9c 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java
@@ -8,7 +8,6 @@ import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountReposi
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;
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;
@@ -58,7 +57,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCl
class ListSepaMandates {
@Test
- void globalAdmin_canViewAllSepaMandates_ifNoCriteriaGiven() throws JSONException {
+ void globalAdmin_canViewAllSepaMandates_ifNoCriteriaGiven() {
RestAssured // @formatter:off
.given()
@@ -97,6 +96,36 @@ class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCl
"""));
// @formatter:on
}
+
+ @Test
+ void globalAdmin_canFindSepaMandateByName() {
+
+ RestAssured // @formatter:off
+ .given()
+ .header("current-subject", "superuser-alex@hostsharing.net")
+ .port(port)
+ .when()
+ .get("http://localhost/api/hs/office/sepamandates?iban=DE02120300000000202051")
+ .then().log().all().assertThat()
+ .statusCode(200)
+ .contentType("application/json")
+ .log().all()
+ .body("", lenientlyEquals("""
+ [
+ {
+ "debitor": { "debitorNumber": 1000111 },
+ "bankAccount": {
+ "iban": "DE02120300000000202051",
+ "holder": "First GmbH"
+ },
+ "reference": "ref-10001-11",
+ "validFrom": "2022-10-01",
+ "validTo": "2026-12-31"
+ }
+ ]
+ """));
+ // @formatter:on
+ }
}
@Nested
diff --git a/src/test/java/net/hostsharing/hsadminng/reflection/AnnotationFinder.java b/src/test/java/net/hostsharing/hsadminng/reflection/AnnotationFinder.java
new file mode 100644
index 00000000..9a626e43
--- /dev/null
+++ b/src/test/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;
+ }
+}