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