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)$ + + $endif$ +
+
+ $if(toc)$ +
+
+ + $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 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; + } +}