feature/use-case-acceptance-tests (#116)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #116
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig 2024-10-30 11:40:36 +01:00
parent c181500a1d
commit 3b94f117fb
41 changed files with 1868 additions and 49 deletions

View File

@ -90,6 +90,20 @@ Acceptance-tests, are blackbox-tests and do <u>not</u> 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.

View File

@ -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);

View File

@ -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 <T extends Annotation> Optional<T> findCallerAnnotation(
final Class<T> annotationClassToFind,
final Class<? extends Annotation> annotationClassToStopLookup
) {
for (var element : Thread.currentThread().getStackTrace()) {
final var clazz = Class.forName(element.getClassName());
final var method = getMethodFromStackElement(clazz, element);
// Check if the method is annotated with the desired annotation
if (method != null) {
if (method.isAnnotationPresent(annotationClassToFind)) {
return Optional.of(method.getAnnotation(annotationClassToFind));
} else if (method.isAnnotationPresent(annotationClassToStopLookup)) {
return empty();
}
}
}
return empty();
}
private static Method getMethodFromStackElement(Class<?> clazz, StackTraceElement element) {
for (var method : clazz.getDeclaredMethods()) {
if (method.getName().equals(element.getMethodName())) {
return method;
}
}
return null;
}
}

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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;

View File

@ -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(

View File

@ -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(

View File

@ -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));

View File

@ -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));

View File

@ -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(

View File

@ -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")

View File

@ -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": {

View File

@ -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();
}
}

View File

@ -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 {};
}

View File

@ -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

View File

@ -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();
}

View File

@ -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<T extends UseCase<T>>(Class<T> useCase, UUID uuid) {
@Override
public String toString() {
return uuid.toString();
}
}
private final static Map<String, Alias<?>> aliases = new HashMap<>();
private final static Map<String, Object> 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<String> allOf(final String value, final String explicitly, final String[] implicitly) {
final var all = new HashSet<String>();
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<String, Object> knowVariables() {
final var map = new LinkedHashMap<String, Object>();
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;
}
}

View File

@ -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<String, Object> properties;
private final StringBuilder resolved = new StringBuilder();
private int position = 0;
public TemplateResolver(final String template, final Map<String, Object> 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());
}
}

View File

@ -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<String, ?> 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<String, ?> 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");