Merge branch 'master' into TP-20240927-importfixes

This commit is contained in:
Dev und Test fuer hsadminng 2024-11-07 06:09:54 +01:00
commit 070e63c7b8
67 changed files with 2931 additions and 126 deletions

36
Jenkinsfile vendored
View File

@ -26,9 +26,35 @@ pipeline {
}
}
stage ('Compile & Test') {
stage ('Compile') {
steps {
sh './gradlew clean check --no-daemon -x pitest -x dependencyCheckAnalyze'
sh './gradlew clean processSpring compileJava compileTestJava --no-daemon'
}
}
stage ('Tests') {
parallel {
stage('Unit-/Integration/Acceptance-Tests') {
steps {
sh './gradlew check --no-daemon -x pitest -x dependencyCheckAnalyze -x importOfficeData -x importHostingAssets'
}
}
stage('Import-Tests') {
steps {
sh './gradlew importOfficeData importHostingAssets --no-daemon'
}
}
stage ('Scenario-Tests') {
steps {
sh './gradlew scenarioTests --no-daemon'
}
}
}
}
stage ('Check') {
steps {
sh './gradlew check -x pitest -x dependencyCheckAnalyze --no-daemon'
}
}
}
@ -45,6 +71,12 @@ pipeline {
sourcePattern: 'src/main/java'
)
// archive scenario-test reports in HTML format
sh '''
./gradlew convertMarkdownToHtml
'''
archiveArtifacts artifacts: 'doc/scenarios/*.html', allowEmptyArchive: true
// cleanup workspace
cleanWs()
}

View File

@ -255,7 +255,7 @@ test {
'net.hostsharing.hsadminng.**.generated.**',
]
useJUnitPlatform {
excludeTags 'import'
excludeTags 'importOfficeData', 'importHostingData', 'scenarioTest'
}
}
jacocoTestReport {
@ -344,6 +344,17 @@ tasks.register('importHostingAssets', Test) {
mustRunAfter spotlessJava
}
tasks.register('scenarioTests', Test) {
useJUnitPlatform {
includeTags 'scenarioTest'
}
group 'verification'
description 'run the import jobs as tests'
mustRunAfter spotlessJava
}
// pitest mutation testing
pitest {
targetClasses = ['net.hostsharing.hsadminng.**']
@ -391,3 +402,45 @@ tasks.named("dependencyUpdates").configure {
isNonStable(it.candidate.version)
}
}
// Generate HTML from Markdown scenario-test-reports using Pandoc:
tasks.register('convertMarkdownToHtml') {
description = 'Generates HTML from Markdown scenario-test-reports using Pandoc.'
group = 'Conversion'
// Define the template file and input directory
def templateFile = file('doc/scenarios/template.html')
// Task configuration and execution
doFirst {
// Check if pandoc is installed
try {
exec {
commandLine 'pandoc', '--version'
}
} catch (Exception) {
throw new GradleException("Pandoc is not installed or not found in the system path.")
}
// Check if the template file exists
if (!templateFile.exists()) {
throw new GradleException("Template file 'doc/scenarios/template.html' not found.")
}
}
doLast {
// Gather all Markdown files in the current directory
fileTree(dir: '.', include: 'doc/scenarios/*.md').each { file ->
// Corrected way to create the output file path
def outputFile = new File(file.parent, file.name.replaceAll(/\.md$/, '.html'))
// Execute pandoc for each markdown file
exec {
commandLine 'pandoc', file.absolutePath, '--template', templateFile.absolutePath, '-o', outputFile.absolutePath
}
println "Converted ${file.name} to ${outputFile.name}"
}
}
}

124
doc/scenarios/template.html Normal file
View File

@ -0,0 +1,124 @@
<!doctype html>
<html $if(lang)$ lang="$lang$" $endif$>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--[if lt IE 9]>
<script src="http://css3-mediaqueries-js.googlecode.com/svn/trunk/css3-mediaqueries.js"></script>
<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Style-Type" content="text/css" />
<!-- <link rel="stylesheet" type="text/css" href="template.css" /> -->
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/template.css" />
<link href="https://vjs.zencdn.net/5.4.4/video-js.css" rel="stylesheet" />
<script src="https://code.jquery.com/jquery-2.2.1.min.js"></script>
<!-- <script type='text/javascript' src='menu/js/jquery.cookie.js'></script> -->
<!-- <script type='text/javascript' src='menu/js/jquery.hoverIntent.minified.js'></script> -->
<!-- <script type='text/javascript' src='menu/js/jquery.dcjqaccordion.2.7.min.js'></script> -->
<!-- <link href="menu/css/skins/blue.css" rel="stylesheet" type="text/css" /> -->
<!-- <link href="menu/css/skins/graphite.css" rel="stylesheet" type="text/css" /> -->
<!-- <link href="menu/css/skins/grey.css" rel="stylesheet" type="text/css" /> -->
<!-- <script src="https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script> -->
<!-- <script src="script.js"></script> -->
<!-- <script src="jquery.sticky-kit.js "></script> -->
<script type='text/javascript' src='https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/js/jquery.cookie.js'></script>
<script type='text/javascript' src='https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/js/jquery.hoverIntent.minified.js'></script>
<script type='text/javascript' src='https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/js/jquery.dcjqaccordion.2.7.min.js'></script>
<link href="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/css/skins/blue.css" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/css/skins/graphite.css" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/css/skins/grey.css" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/gh/ryangrose/easy-pandoc-templates@948e28e5/css/elegant_bootstrap.css" rel="stylesheet" type="text/css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.4/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
<script src="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/script.js"></script>
<script src="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/jquery.sticky-kit.js"></script>
<meta name="generator" content="pandoc" />
$for(author-meta)$
<meta name="author" content="$author-meta$" />
$endfor$
$if(date-meta)$
<meta name="date" content="$date-meta$" />
$endif$
<title>$if(title-prefix)$$title-prefix$ - $endif$$pagetitle$</title>
<style type="text/css">code{white-space: pre;}</style>
$if(quotes)$
<style type="text/css">q { quotes: "“" "”" "" ""; }</style>
$endif$
$if(highlighting-css)$
<style type="text/css">
$highlighting-css$
</style>
$endif$
$for(css)$
<link rel="stylesheet" href="$css$" $if(html5)$$else$type="text/css" $endif$/>
$endfor$
$if(math)$
$math$
$endif$
$for(header-includes)$
$header-includes$
$endfor$
</head>
<body>
$if(title)$
<div class="navbar navbar-static-top">
<div class="navbar-inner">
<div class="container">
<span class="doc-title">$title$</span>
<ul class="nav pull-right doc-info">
$for(author)$
<li><p class="navbar-text">$author$</p></li>
$endfor$
$if(date)$
<li><p class="navbar-text">$date$</p></li>
$endif$
</ul>
</div>
</div>
</div>
$endif$
<div class="container">
<div class="row">
$if(toc)$
<div id="$idprefix$TOC" class="span3">
<div class="well toc">
$toc$
</div>
</div>
$endif$
<div class="span$if(toc)$9$else$12$endif$">
$if(abstract)$
<H1>$abstract-title$</H1>
$abstract$
$endif$
$for(include-before)$
$include-before$
$endfor$
$body$
$for(include-after)$
$include-after$
$endfor$
</div>
</div>
</div>
<script src="https://vjs.zencdn.net/5.4.4/video.js"></script>
</body>
</html>

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

@ -1,10 +1,6 @@
FROM eclipse-temurin:21-jdk
RUN apt-get update && \
apt-get install -y bind9-utils && \
apt-get install -y bind9-utils pandoc && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# RUN mkdir /opt/app
# COPY japp.jar /opt
# CMD ["java", "-jar", "/opt/app/japp.jar"]

View File

@ -54,8 +54,14 @@ public class HsOfficeContact implements Stringifyable, BaseEntity<HsOfficeContac
@Column(name = "caption")
private String caption;
@Builder.Default
@Setter(AccessLevel.NONE)
@Type(JsonType.class)
@Column(name = "postaladdress")
private String postalAddress; // multiline free-format text
private Map<String, String> postalAddress = new HashMap<>();
@Transient
private PatchableMapWrapper<String> postalAddressWrapper;
@Builder.Default
@Setter(AccessLevel.NONE)
@ -75,6 +81,17 @@ public class HsOfficeContact implements Stringifyable, BaseEntity<HsOfficeContac
@Transient
private PatchableMapWrapper<String> phoneNumbersWrapper;
public PatchableMapWrapper<String> getPostalAddress() {
return PatchableMapWrapper.of(
postalAddressWrapper,
(newWrapper) -> {postalAddressWrapper = newWrapper;},
postalAddress);
}
public void putPostalAddress(Map<String, String> newPostalAddress) {
getPostalAddress().assign(newPostalAddress);
}
public PatchableMapWrapper<String> getEmailAddresses() {
return PatchableMapWrapper.of(
emailAddressesWrapper,

View File

@ -18,7 +18,8 @@ class HsOfficeContactEntityPatcher implements EntityPatcher<HsOfficeContactPatch
@Override
public void apply(final HsOfficeContactPatchResource resource) {
OptionalFromJson.of(resource.getCaption()).ifPresent(entity::setCaption);
OptionalFromJson.of(resource.getPostalAddress()).ifPresent(entity::setPostalAddress);
Optional.ofNullable(resource.getPostalAddress())
.ifPresent(r -> entity.getPostalAddress().patch(KeyValueMap.from(resource.getPostalAddress())));
Optional.ofNullable(resource.getEmailAddresses())
.ifPresent(r -> entity.getEmailAddresses().patch(KeyValueMap.from(resource.getEmailAddresses())));
Optional.ofNullable(resource.getPhoneNumbers())

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

@ -47,7 +47,7 @@ public interface HsOfficeRelationRbacRepository extends Repository<HsOfficeRelat
OR lower(rel.anchor.givenName) LIKE :personData OR lower(rel.holder.givenName) LIKE :personData )
AND ( :contactData IS NULL
OR lower(rel.contact.caption) LIKE :contactData
OR lower(rel.contact.postalAddress) LIKE :contactData
OR lower(CAST(rel.contact.postalAddress AS String)) LIKE :contactData
OR lower(CAST(rel.contact.emailAddresses AS String)) LIKE :contactData
OR lower(CAST(rel.contact.phoneNumbers AS String)) LIKE :contactData )
""")

View File

@ -12,7 +12,7 @@ components:
caption:
type: string
postalAddress:
type: string
$ref: '#/components/schemas/HsOfficeContactPostalAddress'
emailAddresses:
$ref: '#/components/schemas/HsOfficeContactEmailAddresses'
phoneNumbers:
@ -24,7 +24,7 @@ components:
caption:
type: string
postalAddress:
type: string
$ref: '#/components/schemas/HsOfficeContactPostalAddress'
emailAddresses:
$ref: '#/components/schemas/HsOfficeContactEmailAddresses'
phoneNumbers:
@ -39,21 +39,48 @@ components:
type: string
nullable: true
postalAddress:
type: string
nullable: true
$ref: '#/components/schemas/HsOfficeContactPostalAddress'
emailAddresses:
$ref: '#/components/schemas/HsOfficeContactEmailAddresses'
phoneNumbers:
$ref: '#/components/schemas/HsOfficeContactPhoneNumbers'
HsOfficeContactPostalAddress:
# forces generating a java.lang.Object containing a Map, instead of a class with fixed properties
anyOf:
- type: object
properties:
firm:
type: string
nullable: true
name:
type: string
nullable: true
co:
type: string
nullable: true
street:
type: string
nullable: true
zipcode:
type: string
nullable: true
city:
type: string
nullable: true
country:
type: string
nullable: true
additionalProperties: true
HsOfficeContactEmailAddresses:
# forces generating a java.lang.Object containing a Map, instead of class HsOfficeContactEmailAddresses
# forces generating a java.lang.Object containing a Map, instead of a class with fixed properties
anyOf:
- type: object
additionalProperties: true
HsOfficeContactPhoneNumbers:
# forces generating a java.lang.Object containing a Map, instead of class HsOfficeContactEmailAddresses
# forces generating a java.lang.Object containing a Map, instead of a class with fixed properties
anyOf:
- type: object
properties:

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

@ -42,6 +42,7 @@ components:
format: uuid
nullable: true
# arbitrary relation with explicit type
HsOfficeRelationInsert:
type: object
properties:
@ -65,3 +66,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

@ -7,7 +7,7 @@ get:
parameters:
- $ref: 'auth.yaml#/components/parameters/currentSubject'
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: name
- name: iban
in: query
required: false
schema:

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

@ -9,7 +9,7 @@ create table if not exists hs_office.contact
uuid uuid unique references rbac.object (uuid) initially deferred,
version int not null default 0,
caption varchar(128) not null,
postalAddress text,
postalAddress jsonb not null,
emailAddresses jsonb not null,
phoneNumbers jsonb not null
);

View File

@ -11,7 +11,6 @@
create or replace procedure hs_office.contact_create_test_data(contCaption varchar)
language plpgsql as $$
declare
postalAddr varchar;
emailAddr varchar;
begin
emailAddr = 'contact-admin@' || base.cleanIdentifier(contCaption) || '.example.com';
@ -19,14 +18,18 @@ begin
perform rbac.create_subject(emailAddr);
call base.defineContext('creating contact test-data', null, emailAddr);
postalAddr := E'Vorname Nachname\nStraße Hnr\nPLZ Stadt';
raise notice 'creating test contact: %', contCaption;
insert
into hs_office.contact (caption, postaladdress, emailaddresses, phonenumbers)
values (
contCaption,
postalAddr,
( '{ ' ||
-- '"name": "' || contCaption || '",' ||
-- '"street": "Somewhere 1",' ||
-- '"zipcode": "12345",' ||
-- '"city": "Where-Ever",' ||
'"country": "Germany"' ||
'}')::jsonb,
('{ "main": "' || emailAddr || '" }')::jsonb,
('{ "phone_office": "+49 123 1234567" }')::jsonb
);

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

@ -110,7 +110,6 @@ public class HsHostingAssetControllerRestTest {
"caption": "some fake cloud-server",
"alarmContact": {
"caption": "some contact",
"postalAddress": "address of some contact",
"emailAddresses": {
"main": "some-contact@example.com"
}
@ -141,7 +140,6 @@ public class HsHostingAssetControllerRestTest {
"caption": "some fake managed-server",
"alarmContact": {
"caption": "some contact",
"postalAddress": "address of some contact",
"emailAddresses": {
"main": "some-contact@example.com"
}

View File

@ -1161,7 +1161,7 @@ public abstract class BaseOfficeDataImport extends CsvDataImport {
contactRecord.getString("last_name"),
contactRecord.getString("firma")));
contact.putEmailAddresses(Map.of("main", contactRecord.getString("email")));
contact.setPostalAddress(toAddress(contactRecord));
contact.putPostalAddress(toAddress(contactRecord));
contact.putPhoneNumbers(toPhoneNumbers(contactRecord));
contacts.put(contactRecord.getInteger("contact_id"), contact);
@ -1181,36 +1181,23 @@ public abstract class BaseOfficeDataImport extends CsvDataImport {
return phoneNumbers;
}
private String toAddress(final Record rec) {
final var result = new StringBuilder();
private Map<String, String> toAddress(final Record rec) {
final var result = new LinkedHashMap<String, String>();
final var name = toName(
rec.getString("salut"),
rec.getString("title"),
rec.getString("first_name"),
rec.getString("last_name"));
if (isNotBlank(name))
result.append(name + "\n");
result.put("name", name);
if (isNotBlank(rec.getString("firma")))
result.append(rec.getString("firma") + "\n");
if (isNotBlank(rec.getString("co")))
result.append("c/o " + rec.getString("co") + "\n");
if (isNotBlank(rec.getString("street")))
result.append(rec.getString("street") + "\n");
final var zipcodeAndCity = toZipcodeAndCity(rec);
if (isNotBlank(zipcodeAndCity))
result.append(zipcodeAndCity + "\n");
return result.toString();
}
result.put("firm", name);
private String toZipcodeAndCity(final Record rec) {
final var result = new StringBuilder();
if (isNotBlank(rec.getString("country")))
result.append(rec.getString("country") + " ");
if (isNotBlank(rec.getString("zipcode")))
result.append(rec.getString("zipcode") + " ");
if (isNotBlank(rec.getString("city")))
result.append(rec.getString("city"));
return result.toString();
List.of("co", "street", "zipcode", "city", "country").forEach(key -> {
if (isNotBlank(rec.getString(key)))
result.put(key, rec.getString(key));
});
return result;
}
private String toCaption(

View File

@ -21,10 +21,13 @@ import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import static java.util.Map.entry;
import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasEntry;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
@ -214,7 +217,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
"emailAddresses": {
"main": "patched@example.org"
},
"postalAddress": "Patched Address",
"postalAddress": {
"extra": "Extra Property",
"co": "P. Patcher",
"street": "Patchstraße 5"
},
"phoneNumbers": {
"phone_office": "+01 100 123456"
}
@ -229,7 +236,10 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("uuid", isUuidValid())
.body("caption", is("Temp patched contact"))
.body("emailAddresses", is(Map.of("main", "patched@example.org")))
.body("postalAddress", is("Patched Address"))
.body("postalAddress", hasEntry("name", givenContact.getPostalAddress().get("name"))) // unchanged
.body("postalAddress", hasEntry("extra", "Extra Property")) // unchanged
.body("postalAddress", hasEntry("co", "P. Patcher")) // patched
.body("postalAddress", hasEntry("street", "Patchstraße 5")) // patched
.body("phoneNumbers", is(Map.of("phone_office", "+01 100 123456")));
// @formatter:on
@ -239,7 +249,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.matches(person -> {
assertThat(person.getCaption()).isEqualTo("Temp patched contact");
assertThat(person.getEmailAddresses()).containsExactlyEntriesOf(Map.of("main", "patched@example.org"));
assertThat(person.getPostalAddress()).isEqualTo("Patched Address");
assertThat(person.getPostalAddress()).containsAllEntriesOf(Map.ofEntries(
entry("name", givenContact.getPostalAddress().get("name")),
entry("co", "P. Patcher"),
entry("street", "Patchstraße 5")
));
assertThat(person.getPhoneNumbers()).containsExactlyEntriesOf(Map.of("phone_office", "+01 100 123456"));
return true;
});
@ -264,7 +278,7 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
"phone_office": "+01 100 123456"
}
}
""")
""")
.port(port)
.when()
.patch("http://localhost/api/hs/office/contacts/" + givenContact.getUuid())
@ -274,7 +288,6 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("uuid", isUuidValid())
.body("caption", is(givenContact.getCaption()))
.body("emailAddresses", is(Map.of("main", "patched@example.org")))
.body("postalAddress", is(givenContact.getPostalAddress()))
.body("phoneNumbers", is(Map.of("phone_office", "+01 100 123456")));
// @formatter:on
@ -283,12 +296,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.matches(person -> {
assertThat(person.getCaption()).isEqualTo(givenContact.getCaption());
assertThat(person.getEmailAddresses()).containsExactlyEntriesOf(Map.of("main", "patched@example.org"));
assertThat(person.getPostalAddress()).isEqualTo(givenContact.getPostalAddress());
assertThat(person.getPostalAddress()).containsExactlyInAnyOrderEntriesOf(givenContact.getPostalAddress());
assertThat(person.getPhoneNumbers()).containsExactlyEntriesOf(Map.of("phone_office", "+01 100 123456"));
return true;
});
}
}
@Nested
@ -361,8 +373,13 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
final var newContact = HsOfficeContactRbacEntity.builder()
.uuid(UUID.randomUUID())
.caption("Temp from " + Context.getCallerMethodNameFromStackFrame(1) )
.postalAddress(Map.ofEntries(
entry("name", RandomStringUtils.randomAlphabetic(6) + " " + RandomStringUtils.randomAlphabetic(10)),
entry("street", RandomStringUtils.randomAlphabetic(10) + randomInt(1, 99)),
entry("zipcode", "D-" + randomInt(10000, 99999)),
entry("city", RandomStringUtils.randomAlphabetic(10))
))
.emailAddresses(Map.of("main", RandomStringUtils.randomAlphabetic(10) + "@example.org"))
.postalAddress("Postal Address " + RandomStringUtils.randomAlphabetic(10))
.phoneNumbers(Map.of("phone_office", "+01 200 " + RandomStringUtils.randomNumeric(8)))
.build();
@ -378,4 +395,8 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
em.createQuery("DELETE FROM HsOfficeContactRbacEntity c WHERE c.caption LIKE 'Temp %'").executeUpdate();
}).assertSuccessful();
}
private int randomInt(final int min, final int max) {
return ThreadLocalRandom.current().nextInt(min, max);
}
}

View File

@ -19,6 +19,19 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
> {
private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID();
private static final Map<String, String> PATCH_POSTAL_ADDRESS = patchMap(
entry("name", "Patty Patch"),
entry("street", "Patchstreet 10"),
entry("zipcode", null),
entry("city", "Hamburg")
);
private static final Map<String, String> PATCHED_POSTAL_ADDRESS = patchMap(
entry("name", "Patty Patch"),
entry("street", "Patchstreet 10"),
entry("city", "Hamburg")
);
private static final Map<String, String> PATCH_EMAIL_ADDRESSES = patchMap(
entry("main", "patched@example.com"),
entry("paul", null),
@ -46,6 +59,11 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
final var entity = new HsOfficeContactRbacEntity();
entity.setUuid(INITIAL_CONTACT_UUID);
entity.setCaption("initial caption");
entity.putPostalAddress(Map.ofEntries(
entry("name", "Ina Initial"),
entry("street", "Initialstraße 50"),
entry("zipcode", "20000"),
entry("city", "Hamburg")));
entity.putEmailAddresses(Map.ofEntries(
entry("main", "initial@example.org"),
entry("paul", "paul@example.com"),
@ -54,7 +72,6 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
entry("phone_office", "+49 40 12345-00"),
entry("phone_mobile", "+49 1555 1234567"),
entry("fax", "+49 40 12345-90")));
entity.setPostalAddress("Initialstraße 50\n20000 Hamburg");
return entity;
}
@ -77,24 +94,26 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
"patched caption",
HsOfficeContactRbacEntity::setCaption),
new SimpleProperty<>(
"resources",
"postalAddress",
HsOfficeContactPatchResource::setPostalAddress,
PATCH_POSTAL_ADDRESS,
HsOfficeContactRbacEntity::putPostalAddress,
PATCHED_POSTAL_ADDRESS)
.notNullable(),
new SimpleProperty<>(
"emailAddresses",
HsOfficeContactPatchResource::setEmailAddresses,
PATCH_EMAIL_ADDRESSES,
HsOfficeContactRbacEntity::putEmailAddresses,
PATCHED_EMAIL_ADDRESSES)
.notNullable(),
new SimpleProperty<>(
"resources",
"phoneNumbers",
HsOfficeContactPatchResource::setPhoneNumbers,
PATCH_PHONE_NUMBERS,
HsOfficeContactRbacEntity::putPhoneNumbers,
PATCHED_PHONE_NUMBERS)
.notNullable(),
new JsonNullableProperty<>(
"patched given name",
HsOfficeContactPatchResource::setPostalAddress,
"patched given name",
HsOfficeContactRbacEntity::setPostalAddress)
.notNullable()
);
}
}

View File

@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.hs.office.contact;
import java.util.Map;
import static java.util.Map.entry;
public class HsOfficeContactRbacTestEntity {
public static final HsOfficeContactRbacEntity TEST_RBAC_CONTACT = hsOfficeContact("some contact", "some-contact@example.com");
@ -9,7 +11,12 @@ public class HsOfficeContactRbacTestEntity {
static public HsOfficeContactRbacEntity hsOfficeContact(final String caption, final String emailAddr) {
return HsOfficeContactRbacEntity.builder()
.caption(caption)
.postalAddress("address of " + caption)
.postalAddress(Map.ofEntries(
entry("name", "M. Meyer"),
entry("street", "Teststraße 11"),
entry("zipcode", "D-12345"),
entry("city", "Berlin")
))
.emailAddresses(Map.of("main", emailAddr))
.build();
}

View File

@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.hs.office.contact;
import java.util.Map;
import static java.util.Map.entry;
public class HsOfficeContactRealTestEntity {
public static final HsOfficeContactRealEntity TEST_REAL_CONTACT = hsOfficeContact("some contact", "some-contact@example.com");
@ -9,7 +11,12 @@ public class HsOfficeContactRealTestEntity {
static public HsOfficeContactRealEntity hsOfficeContact(final String caption, final String emailAddr) {
return HsOfficeContactRealEntity.builder()
.caption(caption)
.postalAddress("address of " + caption)
.postalAddress(Map.ofEntries(
entry("name", "M. Meyer"),
entry("street", "Teststraße 11"),
entry("zipcode", "D-12345"),
entry("city", "Berlin")
))
.emailAddresses(Map.of("main", emailAddr))
.build();
}

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"
@ -463,13 +460,16 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
"type": "DEBITOR",
"contact": {
"caption": "first contact",
"postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
"emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
"phoneNumbers": { "phone_office": "+49 123 1234567" }
"postalAddress": {
"country": "Germany"
},
"emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
"phoneNumbers": { "phone_office": "+49 123 1234567" }
}
}
},
"debitorNumber": 1000111,
"debitorNumberSuffix": 11,
"debitorNumberSuffix": "11",
"partner": {
"partnerNumber": 10001,
"partnerRel": {
@ -479,10 +479,11 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
"mark": null,
"contact": {
"caption": "first contact",
"postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
"emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
"phoneNumbers": { "phone_office": "+49 123 1234567" }
}
"postalAddress": {
"country": "Germany"
},
"emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
"phoneNumbers": { "phone_office": "+49 123 1234567" }
},
"details": {
"registrationOffice": "Hamburg",
@ -581,7 +582,7 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
"contact": { "caption": "fourth contact" }
},
"debitorNumber": 10004${debitorNumberSuffix},
"debitorNumberSuffix": ${debitorNumberSuffix},
"debitorNumberSuffix": "${debitorNumberSuffix}",
"partner": {
"partnerNumber": 10004,
"partnerRel": {

View File

@ -199,7 +199,9 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
"type": "REPRESENTATIVE",
"contact": {
"caption": "first contact",
"postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
"postalAddress": {
"country": "Germany"
},
"emailAddresses": {
"main": "contact-admin@firstcontact.example.com"
},

View File

@ -0,0 +1,339 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.hs.office.scenarios.contact.AddPhoneNumberToContactData;
import net.hostsharing.hsadminng.hs.office.scenarios.contact.AmendContactData;
import net.hostsharing.hsadminng.hs.office.scenarios.contact.RemovePhoneNumberFromContactData;
import net.hostsharing.hsadminng.hs.office.scenarios.contact.ReplaceContactData;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateExternalDebitorForPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSelfDebitorForPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSepaMandateForDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.FinallyDeleteSepaMandateForDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.DontDeleteDefaultDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.InvalidateSepaMandateForDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.membership.CreateMembership;
import net.hostsharing.hsadminng.hs.office.scenarios.partner.AddOperationsContactToPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.partner.CreatePartner;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.DeleteDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.partner.DeletePartner;
import net.hostsharing.hsadminng.hs.office.scenarios.partner.AddRepresentativeToPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.subscription.RemoveOperationsContactFromPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.subscription.SubscribeToMailinglist;
import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFromMailinglist;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
@Tag("scenarioTest")
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = { HsadminNgApplication.class, JpaAttempt.class },
properties = {
"spring.datasource.url=${HSADMINNG_POSTGRES_JDBC_URL:jdbc:tc:postgresql:15.5-bookworm:///scenariosTC}",
"spring.datasource.username=${HSADMINNG_POSTGRES_ADMIN_USERNAME:ADMIN}",
"spring.datasource.password=${HSADMINNG_POSTGRES_ADMIN_PASSWORD:password}",
"hsadminng.superuser=${HSADMINNG_SUPERUSER:superuser-alex@hostsharing.net}"
}
)
@DirtiesContext
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class HsOfficeScenarioTests extends ScenarioTest {
@Test
@Order(1010)
@Produces(explicitly = "Partner: Test AG", implicitly = {"Person: Test AG", "Contact: Test AG - Hamburg"})
void shouldCreateLegalPersonAsPartner() {
new CreatePartner(this)
.given("partnerNumber", 31010)
.given("personType", "LEGAL_PERSON")
.given("tradeName", "Test AG")
.given("contactCaption", "Test AG - Hamburg")
.given("postalAddress", """
"firm": "Test AG",
"street": "Shanghai-Allee 1",
"zipcode": "20123",
"city": "Hamburg",
"country": "Germany"
""")
.given("officePhoneNumber", "+49 40 654321-0")
.given("emailAddress", "hamburg@test-ag.example.org")
.doRun()
.keep();
}
@Test
@Order(1011)
@Produces(explicitly = "Partner: Michelle Matthieu", implicitly = {"Person: Michelle Matthieu", "Contact: Michelle Matthieu"})
void shouldCreateNaturalPersonAsPartner() {
new CreatePartner(this)
.given("partnerNumber", 31011)
.given("personType", "NATURAL_PERSON")
.given("givenName", "Michelle")
.given("familyName", "Matthieu")
.given("contactCaption", "Michelle Matthieu")
.given("postalAddress", """
"name": "Michelle Matthieu",
"street": "An der Wandse 34",
"zipcode": "22123",
"city": "Hamburg",
"country": "Germany"
""")
.given("officePhoneNumber", "+49 40 123456")
.given("emailAddress", "michelle.matthieu@example.org")
.doRun()
.keep();
}
@Test
@Order(1020)
@Requires("Person: Test AG")
@Produces("Representative: Tracy Trust for Test AG")
void shouldAddRepresentativeToPartner() {
new AddRepresentativeToPartner(this)
.given("partnerPersonTradeName", "Test AG")
.given("representativeFamilyName", "Trust")
.given("representativeGivenName", "Tracy")
.given("representativePostalAddress", """
"name": "Michelle Matthieu",
"street": "An der Alster 100",
"zipcode": "20000",
"city": "Hamburg",
"country": "Germany"
""")
.given("representativePhoneNumber", "+49 40 123456")
.given("representativeEMailAddress", "tracy.trust@example.org")
.doRun()
.keep();
}
@Test
@Order(1030)
@Requires("Person: Test AG")
@Produces("Operations-Contact: Dennis Krause for Test AG")
void shouldAddOperationsContactToPartner() {
new AddOperationsContactToPartner(this)
.given("partnerPersonTradeName", "Test AG")
.given("operationsContactFamilyName", "Krause")
.given("operationsContactGivenName", "Dennis")
.given("operationsContactPhoneNumber", "+49 9932 587741")
.given("operationsContactEMailAddress", "dennis.krause@example.org")
.doRun()
.keep();
}
@Test
@Order(1039)
@Requires("Operations-Contact: Dennis Krause for Test AG")
void shouldRemoveOperationsContactFromPartner() {
new RemoveOperationsContactFromPartner(this)
.given("operationsContactPerson", "Dennis Krause")
.doRun();
}
@Test
@Order(1090)
void shouldDeletePartner() {
new DeletePartner(this)
.given("partnerNumber", 31020)
.doRun();
}
@Test
@Order(1100)
@Requires("Partner: Michelle Matthieu")
void shouldAmendContactData() {
new AmendContactData(this)
.given("partnerName", "Matthieu")
.given("newEmailAddress", "michelle@matthieu.example.org")
.doRun();
}
@Test
@Order(1101)
@Requires("Partner: Michelle Matthieu")
void shouldAddPhoneNumberToContactData() {
new AddPhoneNumberToContactData(this)
.given("partnerName", "Matthieu")
.given("phoneNumberKeyToAdd", "mobile")
.given("phoneNumberToAdd", "+49 152 1234567")
.doRun();
}
@Test
@Order(1102)
@Requires("Partner: Michelle Matthieu")
void shouldRemovePhoneNumberFromContactData() {
new RemovePhoneNumberFromContactData(this)
.given("partnerName", "Matthieu")
.given("phoneNumberKeyToRemove", "office")
.doRun();
}
@Test
@Order(1103)
@Requires("Partner: Test AG")
void shouldReplaceContactData() {
new ReplaceContactData(this)
.given("partnerName", "Test AG")
.given("newContactCaption", "Test AG - China")
.given("newPostalAddress", """
"firm": "Test AG",
"name": "Fi Zhong-Kha",
"building": "Thi Chi Koh Building",
"street": "No.2 Commercial Second Street",
"district": "Niushan Wei Wu",
"city": "Dongguan City",
"province": "Guangdong Province",
"country": "China"
""")
.given("newOfficePhoneNumber", "++15 999 654321" )
.given("newEmailAddress", "norden@test-ag.example.org")
.doRun();
}
@Test
@Order(2010)
@Requires("Partner: Test AG")
@Produces("Debitor: Test AG - main debitor")
void shouldCreateSelfDebitorForPartner() {
new CreateSelfDebitorForPartner(this, "Debitor: Test AG - main debitor")
.given("partnerPersonTradeName", "Test AG")
.given("billingContactCaption", "Test AG - billing department")
.given("billingContactEmailAddress", "billing@test-ag.example.org")
.given("debitorNumberSuffix", "00") // TODO.impl: could be assigned automatically, but is not yet
.given("billable", true)
.given("vatId", "VAT123456")
.given("vatCountryCode", "DE")
.given("vatBusiness", true)
.given("vatReverseCharge", false)
.given("defaultPrefix", "tst")
.doRun()
.keep();
}
@Test
@Order(2011)
@Requires("Person: Test AG")
@Produces("Debitor: Billing GmbH")
void shouldCreateExternalDebitorForPartner() {
new CreateExternalDebitorForPartner(this)
.given("partnerPersonTradeName", "Test AG")
.given("billingContactCaption", "Billing GmbH - billing department")
.given("billingContactEmailAddress", "billing@test-ag.example.org")
.given("debitorNumberSuffix", "01")
.given("billable", true)
.given("vatId", "VAT123456")
.given("vatCountryCode", "DE")
.given("vatBusiness", true)
.given("vatReverseCharge", false)
.given("defaultPrefix", "tsx")
.doRun()
.keep();
}
@Test
@Order(2020)
@Requires("Person: Test AG")
void shouldDeleteDebitor() {
new DeleteDebitor(this)
.given("partnerNumber", 31020)
.given("debitorSuffix", "02")
.doRun();
}
@Test
@Order(2020)
@Requires("Debitor: Test AG - main debitor")
@Disabled("see TODO.spec in DontDeleteDefaultDebitor")
void shouldNotDeleteDefaultDebitor() {
new DontDeleteDefaultDebitor(this)
.given("partnerNumber", 31020)
.given("debitorSuffix", "00")
.doRun();
}
@Test
@Order(3100)
@Requires("Debitor: Test AG - main debitor")
@Produces("SEPA-Mandate: Test AG")
void shouldCreateSepaMandateForDebitor() {
new CreateSepaMandateForDebitor(this)
// existing debitor
.given("debitorNumber", "3101000")
// new sepa-mandate
.given("mandateReference", "Test AG - main debitor")
.given("mandateAgreement", "2022-10-12")
.given("mandateValidFrom", "2024-10-15")
// new bank-account
.given("bankAccountHolder", "Test AG - debit bank account")
.given("bankAccountIBAN", "DE02701500000000594937")
.given("bankAccountBIC", "SSKMDEMM")
.doRun()
.keep();
}
@Test
@Order(3108)
@Requires("SEPA-Mandate: Test AG")
void shouldInvalidateSepaMandateForDebitor() {
new InvalidateSepaMandateForDebitor(this)
.given("bankAccountIBAN", "DE02701500000000594937")
.given("mandateValidUntil", "2025-09-30")
.doRun();
}
@Test
@Order(3109)
@Requires("SEPA-Mandate: Test AG")
void shouldFinallyDeleteSepaMandateForDebitor() {
new FinallyDeleteSepaMandateForDebitor(this)
.given("bankAccountIBAN", "DE02701500000000594937")
.doRun();
}
@Test
@Order(4000)
@Requires("Partner: Test AG")
void shouldCreateMembershipForPartner() {
new CreateMembership(this)
.given("partnerName", "Test AG")
.given("memberNumberSuffix", "00")
.given("validFrom", "2024-10-15")
.given("membershipFeeBillable", "true")
.doRun();
}
@Test
@Order(5000)
@Requires("Person: Test AG")
@Produces("Subscription: Michael Miller to operations-announce")
void shouldSubscribeNewPersonAndContactToMailinglist() {
new SubscribeToMailinglist(this)
// TODO.spec: do we need the personType? or is an operational contact always a natural person? what about distribution lists?
.given("partnerPersonTradeName", "Test AG")
.given("subscriberFamilyName", "Miller")
.given("subscriberGivenName", "Michael")
.given("subscriberEMailAddress", "michael.miller@example.org")
.given("mailingList", "operations-announce")
.doRun()
.keep();
}
@Test
@Order(5001)
@Requires("Subscription: Michael Miller to operations-announce")
void shouldUnsubscribeNewPersonAndContactToMailinglist() {
new UnsubscribeFromMailinglist(this)
.given("mailingList", "operations-announce")
.given("subscriberEMailAddress", "michael.miller@example.org")
.doRun();
}
}

View File

@ -0,0 +1,23 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase.HttpResponse;
import java.util.function.Consumer;
public class PathAssertion {
private final String path;
public PathAssertion(final String path) {
this.path = path;
}
@SuppressWarnings({ "unchecked", "rawtypes" })
public Consumer<UseCase.HttpResponse> contains(final String resolvableValue) {
return response -> response.path(path).contains(ScenarioTest.resolve(resolvableValue));
}
public Consumer<HttpResponse> doesNotExist() {
return response -> response.path(path).isNull(); // here, null Optional means key not found in JSON
}
}

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,96 @@
# UseCase-Tests
We define UseCase-tests as test for business-scenarios.
They test positive (successful) scenarios by using the REST-API.
Running these tests also creates test-reports which can be used as documentation about the necessary REST-calls for each scenario.
Clarification: Acceptance tests also test at the REST-API level but are more technical and also test negative (error-) scenarios.
## ... extends ScenarioTest
Each test-method in subclasses of ScenarioTest describes a business-scenario,
each utilizing a main-use-case and given example data for the scenario.
To reduce the number of API-calls, intermediate results can be re-used.
This is controlled by two annotations:
### @Produces(....)
This annotation tells the test-runner that this scenario produces certain business object for re-use.
The UUID of the new business objects are stored in a key-value map using the provided keys.
There are two variants of this annotation:
#### A Single Business Object
```
@Produces("key")
```
This variant is used when there is just a single business-object produced by the use-case.
#### Multiple Business Objects
```
@Produces(explicitly = "main-key", implicitly = {"other-key", ...})
```
This variant is used when multiple business-objects are produced by the use-case,
e.g. a Relation, a Person and a Contact.
The UUID of the business-object produced by the main-use-case gets stored as the key after "explicitly",
the others are listed after "implicitly";
if there is just one, leave out the surrounding braces.
### @Requires(...)
This annotation tells the test-runner that which business objects are required before this scenario can run.
Each subset must be produced by the same producer-method.
## ... extends UseCase
These classes consist of two parts:
### Prerequisites of the Use-Case
The constructor may create prerequisites via `required(...)`.
These do not really belong to the use-case itself,
e.g. create business objects which, in the context of that use-case, would already exist.
This is similar to @Requires(...) just that no other test scenario produces this prerequisite.
Here, use-cases can be re-used, usually with different data.
### The Use-Case Itself
The use-case is implemented by the `run()`-method which contains HTTP-calls.
Each HTTP-call is wrapped into either `obtain(...)` to keep the result in a placeholder variable,
the variable name is also used as a title.
Or it's wrapped into a `withTitle(...)` to assign a title.
The HTTP-call is followed by some assertions, e.g. the HTTP status and JSON-path-expression-matchers.
Use `${...}` for placeholders which need to be replaced with JSON quotes
(e.g. strings are quoted, numbers are not),
`%{...}` for placeholders which need to be rendered raw
and `&{...}` for placeholders which need to get URI-encoded.
If `???` is added before the closing brace, the property is optional.
This means, if it's not available in the properties, `null` is used.
Properties with null-values are removed from the JSON.
If you need to keep a null-value, e.g. to delete a property,
use `NULL` (all caps) in the template (not the variable value).
A special syntax is the infix `???`-operator like in: `${%{var1???}???%{var2???}%{var3???}}`.
In this case the first non-null value is used.
### The Use-Case Verification
The verification-step is implemented by the `verify()`-method which usually contains a HTTP-HTTP-call.
It can also contain a JSON-path verification to check if a certain value is in the result.

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 org.testcontainers.shaded.org.apache.commons.lang3.ObjectUtils;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import static java.util.Arrays.asList;
import static java.util.Optional.ofNullable;
import static org.assertj.core.api.Assertions.assertThat;
public abstract class ScenarioTest extends ContextBasedTest {
final static String RUN_AS_USER = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented
record Alias<T extends UseCase<T>>(Class<T> useCase, UUID uuid) {
@Override
public String toString() {
return ObjectUtils.toString(uuid);
}
}
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();
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,200 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import org.apache.commons.lang3.StringUtils;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class TemplateResolver {
private final static Pattern pattern = Pattern.compile(",(\\s*})", Pattern.MULTILINE);
private static final String IF_NOT_FOUND_SYMBOL = "???";
enum PlaceholderPrefix {
RAW('%') {
@Override
String convert(final Object value) {
return value != null ? value.toString() : "";
}
},
JSON_QUOTED('$'){
@Override
String convert(final Object value) {
return jsonQuoted(value);
}
},
URI_ENCODED('&'){
@Override
String convert(final Object value) {
return value != null ? URLEncoder.encode(value.toString(), StandardCharsets.UTF_8) : "";
}
};
private final char prefixChar;
PlaceholderPrefix(final char prefixChar) {
this.prefixChar = prefixChar;
}
static boolean contains(final char givenChar) {
return Arrays.stream(values()).anyMatch(p -> p.prefixChar == givenChar);
}
static PlaceholderPrefix ofPrefixChar(final char givenChar) {
return Arrays.stream(values()).filter(p -> p.prefixChar == givenChar).findFirst().orElseThrow();
}
abstract String convert(final Object value);
}
private final String template;
private final Map<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() {
final var resolved = copy();
final var withoutDroppedLines = dropLinesWithNullProperties(resolved);
final var result = removeDanglingCommas(withoutDroppedLines);
return result;
}
private static String removeDanglingCommas(final String withoutDroppedLines) {
return pattern.matcher(withoutDroppedLines).replaceAll("$1");
}
private String dropLinesWithNullProperties(final String text) {
return Arrays.stream(text.split("\n"))
.filter(TemplateResolver::keepLine)
.map(TemplateResolver::keptNullValues)
.collect(Collectors.joining("\n"));
}
private static boolean keepLine(final String line) {
final var trimmed = line.trim();
return !trimmed.endsWith("null,") && !trimmed.endsWith("null");
}
private static String keptNullValues(final String line) {
return line.replace(": NULL", ": null");
}
private String copy() {
while (hasMoreChars()) {
if (PlaceholderPrefix.contains(currentChar()) && nextChar() == '{') {
startPlaceholder(currentChar());
} else {
resolved.append(fetchChar());
}
}
return resolved.toString();
}
private boolean hasMoreChars() {
return position < template.length();
}
private void startPlaceholder(final char intro) {
skipChars(intro + "{");
int nested = 0;
final var placeholder = new StringBuilder();
while (nested > 0 || currentChar() != '}') {
if (currentChar() == '}') {
--nested;
placeholder.append(fetchChar());
} else if (PlaceholderPrefix.contains (currentChar()) && nextChar() == '{') {
++nested;
placeholder.append(fetchChar());
} else {
placeholder.append(fetchChar());
}
}
final var name = new TemplateResolver(placeholder.toString(), properties).resolve();
final var value = propVal(name);
resolved.append(
PlaceholderPrefix.ofPrefixChar(intro).convert(value)
);
skipChar('}');
}
private Object propVal(final String nameExpression) {
if (nameExpression.endsWith(IF_NOT_FOUND_SYMBOL)) {
final String pureName = nameExpression.substring(0, nameExpression.length() - IF_NOT_FOUND_SYMBOL.length());
return properties.get(pureName);
} else if (nameExpression.contains(IF_NOT_FOUND_SYMBOL)) {
final var parts = StringUtils.split(nameExpression, IF_NOT_FOUND_SYMBOL);
return Arrays.stream(parts).filter(Objects::nonNull).findFirst().orElseGet(() -> {
if ( parts[parts.length-1].isEmpty() ) {
// => whole expression ends with IF_NOT_FOUND_SYMBOL, thus last null element was optional
return null;
}
// => last alternative element in expression was null and not optional
throw new IllegalStateException("Missing required value in property-chain: " + nameExpression);
});
} else {
final var val = properties.get(nameExpression);
if (val == null) {
throw new IllegalStateException("Missing required property: " + nameExpression);
}
return val;
}
}
private void skipChar(final char expectedChar) {
if (currentChar() != expectedChar) {
throw new IllegalStateException("expected '" + expectedChar + "' but got '" + currentChar() + "'");
}
++position;
}
private void skipChars(final String expectedChars) {
final var nextChars = template.substring(position, position + expectedChars.length());
if ( !nextChars.equals(expectedChars) ) {
throw new IllegalStateException("expected '" + expectedChars + "' but got '" + nextChars + "'");
}
position += expectedChars.length();
}
private char fetchChar() {
if ((position+1) > template.length()) {
throw new IllegalStateException("no more characters. resolved so far: " + resolved);
}
final var currentChar = currentChar();
++position;
return currentChar;
}
private char currentChar() {
if (position >= template.length()) {
throw new IllegalStateException("no more characters, maybe closing bracelet missing in template: '''\n" + template + "\n'''");
}
return template.charAt(position);
}
private char nextChar() {
if ((position+1) >= template.length()) {
throw new IllegalStateException("no more characters. resolved so far: " + resolved);
}
return template.charAt(position+1);
}
private static String jsonQuoted(final Object value) {
return switch (value) {
case null -> null;
case Boolean bool -> bool.toString();
case Number number -> number.toString();
case String string -> "\"" + string.replace("\n", "\\n") + "\"";
default -> "\"" + value + "\"";
};
}
}

View File

@ -0,0 +1,73 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class TemplateResolverUnitTest {
@Test
void resolveTemplate() {
final var resolved = new TemplateResolver("""
with optional JSON quotes:
${boolean},
${numeric},
${simple placeholder},
${nested %{name}},
${with-special-chars}
and without quotes:
%{boolean},
%{numeric},
%{simple placeholder},
%{nested %{name}},
%{with-special-chars}
and uri-encoded:
&{boolean},
&{numeric},
&{simple placeholder},
&{nested %{name}},
&{with-special-chars}
""",
Map.ofEntries(
Map.entry("name", "placeholder"),
Map.entry("boolean", true),
Map.entry("numeric", 42),
Map.entry("simple placeholder", "einfach"),
Map.entry("nested placeholder", "verschachtelt"),
Map.entry("with-special-chars", "3&3 AG")
)).resolve();
assertThat(resolved).isEqualTo("""
with optional JSON quotes:
true,
42,
"einfach",
"verschachtelt",
"3&3 AG"
and without quotes:
true,
42,
einfach,
verschachtelt,
3&3 AG
and uri-encoded:
true,
42,
einfach,
verschachtelt,
3%263+AG
""".trim());
}
}

View File

@ -0,0 +1,92 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.TestInfo;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
public class TestReport {
private final Map<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");
}
public void printPara(final String output) {
printLine("\n" +output + "\n");
}
public void close() {
if (markdownReport != null) {
markdownReport.close();
}
}
private static Object orderNumber(final Method method) {
return method.getAnnotation(Order.class).value();
}
private String appendUUIDKey(String multilineText) {
final var lines = multilineText.split("\\r?\\n");
final var result = new StringBuilder();
for (String line : lines) {
for (Map.Entry<String, ?> 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--;
}
}

View File

@ -0,0 +1,369 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.JsonPath;
import io.restassured.http.ContentType;
import lombok.Getter;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.reflection.AnnotationFinder;
import org.apache.commons.collections4.map.LinkedMap;
import org.assertj.core.api.OptionalAssert;
import org.hibernate.AssertionFailure;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import static java.net.URLEncoder.encode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.platform.commons.util.StringUtils.isBlank;
import static org.junit.platform.commons.util.StringUtils.isNotBlank;
public abstract class UseCase<T extends 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<String, Function<String, UseCase<?>>> requirements = new LinkedMap<>();
private final String resultAlias;
private final Map<String, Object> 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<String, UseCase<?>> 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", "<br>") + " |"));
testReport.printLine("");
testReport.silent(() ->
requirements.forEach((alias, factory) -> {
if (!ScenarioTest.containsAlias(alias)) {
factory.apply(alias).run().keep();
}
})
);
final var response = run();
verify();
return response;
}
protected abstract HttpResponse run();
protected void verify() {
}
public final UseCase<T> 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<HttpResponse> http,
final Function<HttpResponse, String> extractor,
final String... extraInfo) {
withTitle(ScenarioTest.resolve(alias), () -> {
final var response = http.get().keep(extractor);
Arrays.stream(extraInfo).forEach(testReport::printPara);
return response;
});
}
public final void obtain(final String alias, final Supplier<HttpResponse> http, final String... extraInfo) {
withTitle(ScenarioTest.resolve(alias), () -> {
final var response = http.get().keep();
Arrays.stream(extraInfo).forEach(testReport::printPara);
return response;
});
}
public HttpResponse withTitle(final String title, final Supplier<HttpResponse> code) {
this.nextTitle = title;
final var response = code.get();
this.nextTitle = null;
return response;
}
@SneakyThrows
public final HttpResponse httpGet(final String uriPathWithPlaceholders) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
final var request = HttpRequest.newBuilder()
.GET()
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("current-subject", ScenarioTest.RUN_AS_USER)
.timeout(Duration.ofSeconds(10))
.build();
final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.GET, uriPath, null, response);
}
@SneakyThrows
public final HttpResponse httpPost(final String uriPathWithPlaceholders, final JsonTemplate bodyJsonTemplate) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
final var requestBody = bodyJsonTemplate.resolvePlaceholders();
final var request = HttpRequest.newBuilder()
.POST(BodyPublishers.ofString(requestBody))
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json")
.header("current-subject", ScenarioTest.RUN_AS_USER)
.timeout(Duration.ofSeconds(10))
.build();
final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.POST, uriPath, requestBody, response);
}
@SneakyThrows
public final HttpResponse httpPatch(final String uriPathWithPlaceholders, final JsonTemplate bodyJsonTemplate) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
final var requestBody = bodyJsonTemplate.resolvePlaceholders();
final var request = HttpRequest.newBuilder()
.method(HttpMethod.PATCH.toString(), BodyPublishers.ofString(requestBody))
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json")
.header("current-subject", ScenarioTest.RUN_AS_USER)
.timeout(Duration.ofSeconds(10))
.build();
final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.PATCH, uriPath, requestBody, response);
}
@SneakyThrows
public final HttpResponse httpDelete(final String uriPathWithPlaceholders) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
final var request = HttpRequest.newBuilder()
.DELETE()
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json")
.header("current-subject", ScenarioTest.RUN_AS_USER)
.timeout(Duration.ofSeconds(10))
.build();
final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.DELETE, uriPath, null, response);
}
protected PathAssertion path(final String path) {
return new PathAssertion(path);
}
protected void verify(
final String title,
final Supplier<UseCase.HttpResponse> http,
final Consumer<UseCase.HttpResponse>... assertions) {
withTitle(ScenarioTest.resolve(title), () -> {
final var response = http.get();
Arrays.stream(assertions).forEach(assertion -> assertion.accept(response));
return response;
});
}
public final UUID uuid(final String alias) {
return ScenarioTest.uuid(alias);
}
public String uriEncoded(final String text) {
return encode(ScenarioTest.resolve(text), StandardCharsets.UTF_8);
}
public static class JsonTemplate {
private final String template;
private JsonTemplate(final String jsonTemplate) {
this.template = jsonTemplate;
}
String resolvePlaceholders() {
return ScenarioTest.resolve(template);
}
}
public final class HttpResponse {
@Getter
private final java.net.http.HttpResponse<String> 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<String> response
) {
this.response = response;
this.status = HttpStatus.valueOf(response.statusCode());
if (this.status == HttpStatus.CREATED) {
final var location = response.headers().firstValue("Location").orElseThrow();
assertThat(location).startsWith("http://localhost:");
locationUuid = UUID.fromString(location.substring(location.lastIndexOf('/') + 1));
}
reportRequestAndResponse(httpMethod, uri, requestBody);
}
public HttpResponse expecting(final HttpStatus httpStatus) {
assertThat(HttpStatus.valueOf(response.statusCode())).isEqualTo(httpStatus);
return this;
}
public HttpResponse expecting(final ContentType contentType) {
assertThat(response.headers().firstValue("content-type"))
.contains(contentType.toString());
return this;
}
public HttpResponse keep(final Function<HttpResponse, String> extractor) {
final var alias = nextTitle != null ? nextTitle : resultAlias;
assertThat(alias).as("cannot keep result, no alias found").isNotNull();
final var value = extractor.apply(this);
ScenarioTest.putAlias(
alias,
new ScenarioTest.Alias<>(UseCase.this.getClass(), UUID.fromString(value)));
return this;
}
public HttpResponse keep() {
final var alias = nextTitle != null ? nextTitle : resultAlias;
assertThat(alias).as("cannot keep result, no alias found").isNotNull();
ScenarioTest.putAlias(
alias,
new ScenarioTest.Alias<>(UseCase.this.getClass(), locationUuid));
return this;
}
@SneakyThrows
public HttpResponse expectArrayElements(final int expectedElementCount) {
final var rootNode = objectMapper.readTree(response.body());
assertThat(rootNode.isArray()).as("array expected, but got: " + response.body()).isTrue();
final var root = (List<?>) objectMapper.readValue(response.body(), new TypeReference<List<Object>>() {
});
assertThat(root.size()).as("unexpected number of array elements").isEqualTo(expectedElementCount);
return this;
}
@SneakyThrows
public String getFromBody(final String path) {
return JsonPath.parse(response.body()).read(ScenarioTest.resolve(path));
}
@SneakyThrows
public Optional<String> getFromBodyAsOptional(final String path) {
try {
return Optional.ofNullable(JsonPath.parse(response.body()).read(ScenarioTest.resolve(path)));
} catch (final Exception e) {
return null; // means the property did not exist at all, not that it was there with value null
}
}
@SneakyThrows
public OptionalAssert<String> path(final String path) {
return assertThat(getFromBodyAsOptional(path));
}
@SneakyThrows
private void reportRequestAndResponse(final HttpMethod httpMethod, final String uri, final String requestBody) {
// the title
if (nextTitle != null) {
testReport.printLine("\n### " + nextTitle + "\n");
} else if (resultAlias != null) {
testReport.printLine("\n### " + resultAlias + "\n");
} else {
fail("please wrap the http...-call in the UseCase using `withTitle(...)`");
}
// the request
testReport.printLine("```");
testReport.printLine(httpMethod.name() + " " + uri);
testReport.printLine((requestBody != null ? requestBody.trim() : ""));
// the response
testReport.printLine("=> status: " + status + " " + (locationUuid != null ? locationUuid : ""));
if (httpMethod == HttpMethod.GET || status.isError()) {
final var jsonNode = objectMapper.readTree(response.body());
final var prettyJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode);
testReport.printLine(prettyJson);
}
testReport.printLine("```");
testReport.printLine("");
}
}
protected T self() {
//noinspection unchecked
return (T) this;
}
private static @Nullable String getResultAliasFromProducesAnnotationInCallStack() {
return AnnotationFinder.findCallerAnnotation(Produces.class, Test.class)
.map(produces -> oneOf(produces.value(), produces.explicitly()))
.orElse(null);
}
private static String oneOf(final String one, final String another) {
if (isNotBlank(one) && isBlank(another)) {
return one;
} else if (isBlank(one) && isNotBlank(another)) {
return another;
}
throw new AssertionFailure("exactly one value required, but got '" + one + "' and '" + another + "'");
}
private String title(String resultAlias) {
return getClass().getSimpleName().replaceAll("([a-z])([A-Z]+)", "$1 $2") + " => " + resultAlias;
}
}

View File

@ -0,0 +1,16 @@
package net.hostsharing.hsadminng.hs.office.scenarios;
import static org.assertj.core.api.Assumptions.assumeThat;
public class UseCaseNotImplementedYet extends UseCase<UseCaseNotImplementedYet> {
public UseCaseNotImplementedYet(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
assumeThat(false).isTrue(); // makes the test gray
return null;
}
}

View File

@ -0,0 +1,51 @@
package net.hostsharing.hsadminng.hs.office.scenarios.contact;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class AddPhoneNumberToContactData extends UseCase<AddPhoneNumberToContactData> {
public AddPhoneNumberToContactData(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain(
"partnerContactUuid",
() -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].contact.uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
withTitle("Patch the Additional Phone-Number into the Contact", () ->
httpPatch("/api/hs/office/contacts/%{partnerContactUuid}", usingJsonBody("""
{
"phoneNumbers": {
${phoneNumberKeyToAdd}: ${phoneNumberToAdd}
}
}
"""))
.expecting(HttpStatus.OK)
);
return null;
}
@Override
protected void verify() {
verify(
"Verify if the New Phone Number Got Added",
() -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
.expecting(OK).expecting(JSON).expectArrayElements(1),
path("[0].contact.phoneNumbers.%{phoneNumberKeyToAdd}").contains("%{phoneNumberToAdd}")
);
}
}

View File

@ -0,0 +1,46 @@
package net.hostsharing.hsadminng.hs.office.scenarios.contact;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class AmendContactData extends UseCase<AmendContactData> {
public AmendContactData(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("partnerContactUuid", () ->
httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].contact.uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
withTitle("Patch the New Phone Number Into the Contact", () ->
httpPatch("/api/hs/office/contacts/%{partnerContactUuid}", usingJsonBody("""
{
"caption": ${newContactCaption???},
"postalAddress": {
%{newPostalAddress???}
},
"phoneNumbers": {
"office": ${newOfficePhoneNumber???}
},
"emailAddresses": {
"main": ${newEmailAddress???}
}
}
"""))
.expecting(HttpStatus.OK)
);
return null;
}
}

View File

@ -0,0 +1,50 @@
package net.hostsharing.hsadminng.hs.office.scenarios.contact;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class RemovePhoneNumberFromContactData extends UseCase<RemovePhoneNumberFromContactData> {
public RemovePhoneNumberFromContactData(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain(
"partnerContactUuid",
() -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].contact.uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
withTitle("Patch the Additional Phone-Number into the Contact", () ->
httpPatch("/api/hs/office/contacts/%{partnerContactUuid}", usingJsonBody("""
{
"phoneNumbers": {
${phoneNumberKeyToRemove}: NULL
}
}
"""))
.expecting(HttpStatus.OK)
);
return null;
}
@Override
protected void verify() {
verify(
"Verify if the New Phone Number Got Added",
() -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
.expecting(OK).expecting(JSON).expectArrayElements(1),
path("[0].contact.phoneNumbers.%{phoneNumberKeyToRemove}").doesNotExist()
);
}
}

View File

@ -0,0 +1,68 @@
package net.hostsharing.hsadminng.hs.office.scenarios.contact;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
public class ReplaceContactData extends UseCase<ReplaceContactData> {
public ReplaceContactData(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("partnerRelationUuid", () ->
httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
obtain("Contact: %{newContactCaption}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": ${newContactCaption},
"postalAddress": {
%{newPostalAddress???}
},
"phoneNumbers": {
"phone": ${newOfficePhoneNumber???}
},
"emailAddresses": {
"main": ${newEmailAddress???}
}
}
"""))
.expecting(CREATED).expecting(JSON),
"Please check first if that contact already exists, if so, use it's UUID below.",
"If any `postalAddress` sub-properties besides those specified in the API " +
"(currently `firm`, `name`, `co`, `street`, `zipcode`, `city`, `country`) " +
"its values might not appear in external systems.");
withTitle("Replace the Contact-Reference in the Partner-Relation", () ->
httpPatch("/api/hs/office/relations/%{partnerRelationUuid}", usingJsonBody("""
{
"contactUuid": ${Contact: %{newContactCaption}}
}
"""))
.expecting(OK)
);
return null;
}
@Override
protected void verify() {
verify(
"Verify if the Contact-Relation Got Replaced in the Partner-Relation",
() -> httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerName}"))
.expecting(OK).expecting(JSON).expectArrayElements(1),
path("[0].contact.caption").contains("%{newContactCaption}")
);
}
}

View File

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

View File

@ -0,0 +1,68 @@
package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
public class CreateSelfDebitorForPartner extends UseCase<CreateSelfDebitorForPartner> {
public CreateSelfDebitorForPartner(final ScenarioTest testSuite, final String resultAlias) {
super(testSuite, resultAlias);
}
@Override
protected HttpResponse run() {
obtain("partnerPersonUuid", () ->
httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].holder.uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
obtain("BankAccount: Test AG - refund bank account", () ->
httpPost("/api/hs/office/bankaccounts", usingJsonBody("""
{
"holder": "Test AG - refund bank account",
"iban": "DE88100900001234567892",
"bic": "BEVODEBB"
}
"""))
.expecting(CREATED).expecting(JSON)
);
obtain("Contact: Test AG - billing department", () ->
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": ${billingContactCaption},
"emailAddresses": {
"main": ${billingContactEmailAddress}
}
}
"""))
.expecting(CREATED).expecting(JSON)
);
return httpPost("/api/hs/office/debitors", usingJsonBody("""
{
"debitorRel": {
"anchorUuid": ${partnerPersonUuid},
"holderUuid": ${partnerPersonUuid},
"contactUuid": ${Contact: Test AG - billing department}
},
"debitorNumberSuffix": ${debitorNumberSuffix},
"billable": ${billable},
"vatId": ${vatId},
"vatCountryCode": ${vatCountryCode},
"vatBusiness": ${vatBusiness},
"vatReverseCharge": ${vatReverseCharge},
"refundBankAccountUuid": ${BankAccount: Test AG - refund bank account},
"defaultPrefix": ${defaultPrefix}
}
"""))
.expecting(CREATED).expecting(JSON);
}
}

View File

@ -0,0 +1,47 @@
package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
public class CreateSepaMandateForDebitor extends UseCase<CreateSepaMandateForDebitor> {
public CreateSepaMandateForDebitor(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Debitor: Test AG - main debitor", () ->
httpGet("/api/hs/office/debitors?debitorNumber=&{debitorNumber}")
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid")
);
obtain("BankAccount: Test AG - debit bank account", () ->
httpPost("/api/hs/office/bankaccounts", usingJsonBody("""
{
"holder": ${bankAccountHolder},
"iban": ${bankAccountIBAN},
"bic": ${bankAccountBIC}
}
"""))
.expecting(CREATED).expecting(JSON)
);
return httpPost("/api/hs/office/sepamandates", usingJsonBody("""
{
"debitorUuid": ${Debitor: Test AG - main debitor},
"bankAccountUuid": ${BankAccount: Test AG - debit bank account},
"reference": ${mandateReference},
"agreement": ${mandateAgreement},
"validFrom": ${mandateValidFrom}
}
"""))
.expecting(CREATED).expecting(JSON);
}
}

View File

@ -0,0 +1,33 @@
package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import org.springframework.http.HttpStatus;
public class DeleteDebitor extends UseCase<DeleteDebitor> {
public DeleteDebitor(final ScenarioTest testSuite) {
super(testSuite);
requires("Debitor: Test AG - delete debitor", alias -> new CreateSelfDebitorForPartner(testSuite, alias)
.given("partnerPersonTradeName", "Test AG")
.given("billingContactCaption", "Test AG - billing department")
.given("billingContactEmailAddress", "billing@test-ag.example.org")
.given("debitorNumberSuffix", "%{debitorSuffix}")
.given("billable", true)
.given("vatId", "VAT123456")
.given("vatCountryCode", "DE")
.given("vatBusiness", true)
.given("vatReverseCharge", false)
.given("defaultPrefix", "tsy"));
}
@Override
protected HttpResponse run() {
withTitle("Delete the Debitor using its UUID", () ->
httpDelete("/api/hs/office/debitors/&{Debitor: Test AG - delete debitor}")
.expecting(HttpStatus.NO_CONTENT)
);
return null;
}
}

View File

@ -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<DontDeleteDefaultDebitor> {
public DontDeleteDefaultDebitor(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
httpDelete("/api/hs/office/debitors/&{Debitor: Test AG - main debitor}")
// TODO.spec: should be CONFLICT or CLIENT_ERROR for Debitor "00" - but how to delete Partners?
.expecting(HttpStatus.NO_CONTENT);
return null;
}
}

View File

@ -0,0 +1,31 @@
package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class FinallyDeleteSepaMandateForDebitor extends UseCase<FinallyDeleteSepaMandateForDebitor> {
public FinallyDeleteSepaMandateForDebitor(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("SEPA-Mandate: %{bankAccountIBAN}", () ->
httpGet("/api/hs/office/sepamandates?iban=&{bankAccountIBAN}")
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"With production data, the bank-account could be used in multiple SEPA-mandates, make sure to use the right one!"
);
// TODO.spec: When to allow actual deletion of SEPA-mandates? Add constraint accordingly.
return withTitle("Delete the SEPA-Mandate by its UUID", () -> httpDelete("/api/hs/office/sepamandates/&{SEPA-Mandate: %{bankAccountIBAN}}")
.expecting(HttpStatus.NO_CONTENT)
);
}
}

View File

@ -0,0 +1,34 @@
package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class InvalidateSepaMandateForDebitor extends UseCase<InvalidateSepaMandateForDebitor> {
public InvalidateSepaMandateForDebitor(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("SEPA-Mandate: %{bankAccountIBAN}", () ->
httpGet("/api/hs/office/sepamandates?iban=&{bankAccountIBAN}")
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"With production data, the bank-account could be used in multiple SEPA-mandates, make sure to use the right one!"
);
return withTitle("Patch the End of the Mandate into the SEPA-Mandate", () ->
httpPatch("/api/hs/office/sepamandates/&{SEPA-Mandate: %{bankAccountIBAN}}", usingJsonBody("""
{
"validUntil": ${mandateValidUntil}
}
"""))
.expecting(OK).expecting(JSON)
);
}
}

View File

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

View File

@ -0,0 +1,78 @@
package net.hostsharing.hsadminng.hs.office.scenarios.partner;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
public class AddOperationsContactToPartner extends UseCase<AddOperationsContactToPartner> {
public AddOperationsContactToPartner(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Person: %{partnerPersonTradeName}", () ->
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
obtain("Person: %{operationsContactGivenName} %{operationsContactFamilyName}", () ->
httpPost("/api/hs/office/persons", usingJsonBody("""
{
"personType": "NATURAL_PERSON",
"familyName": ${operationsContactFamilyName},
"givenName": ${operationsContactGivenName}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON),
"Please check first if that person already exists, if so, use it's UUID below.",
"**HINT**: operations contacts are always connected to a partner-person, thus a person which is a holder of a partner-relation."
);
obtain("Contact: %{operationsContactGivenName} %{operationsContactFamilyName}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": "%{operationsContactGivenName} %{operationsContactFamilyName}",
"phoneNumbers": {
"main": ${operationsContactPhoneNumber}
},
"emailAddresses": {
"main": ${operationsContactEMailAddress}
}
}
"""))
.expecting(CREATED).expecting(JSON),
"Please check first if that contact already exists, if so, use it's UUID below."
);
return httpPost("/api/hs/office/relations", usingJsonBody("""
{
"type": "OPERATIONS",
"anchorUuid": ${Person: %{partnerPersonTradeName}},
"holderUuid": ${Person: %{operationsContactGivenName} %{operationsContactFamilyName}},
"contactUuid": ${Contact: %{operationsContactGivenName} %{operationsContactFamilyName}}
}
"""))
.expecting(CREATED).expecting(JSON);
}
@Override
protected void verify() {
verify(
"Verify the New OPERATIONS Relation",
() -> httpGet("/api/hs/office/relations?relationType=OPERATIONS&personData=" + uriEncoded(
"%{operationsContactFamilyName}"))
.expecting(OK).expecting(JSON).expectArrayElements(1),
path("[0].contact.caption").contains("%{operationsContactGivenName} %{operationsContactFamilyName}")
);
}
}

View File

@ -0,0 +1,80 @@
package net.hostsharing.hsadminng.hs.office.scenarios.partner;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
public class AddRepresentativeToPartner extends UseCase<AddRepresentativeToPartner> {
public AddRepresentativeToPartner(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Person: %{partnerPersonTradeName}", () ->
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
obtain("Person: %{representativeGivenName} %{representativeFamilyName}", () ->
httpPost("/api/hs/office/persons", usingJsonBody("""
{
"personType": "NATURAL_PERSON",
"familyName": ${representativeFamilyName},
"givenName": ${representativeGivenName}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON),
"Please check first if that person already exists, if so, use it's UUID below.",
"**HINT**: A representative is always a natural person and represents a non-natural-person."
);
obtain("Contact: %{representativeGivenName} %{representativeFamilyName}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": "%{representativeGivenName} %{representativeFamilyName}",
"postalAddress": {
%{representativePostalAddress}
},
"phoneNumbers": {
"main": ${representativePhoneNumber}
},
"emailAddresses": {
"main": ${representativeEMailAddress}
}
}
"""))
.expecting(CREATED).expecting(JSON),
"Please check first if that contact already exists, if so, use it's UUID below."
);
return httpPost("/api/hs/office/relations", usingJsonBody("""
{
"type": "REPRESENTATIVE",
"anchorUuid": ${Person: %{partnerPersonTradeName}},
"holderUuid": ${Person: %{representativeGivenName} %{representativeFamilyName}},
"contactUuid": ${Contact: %{representativeGivenName} %{representativeFamilyName}}
}
"""))
.expecting(CREATED).expecting(JSON);
}
@Override
protected void verify() {
verify(
"Verify the REPRESENTATIVE Relation Got Removed",
() -> httpGet("/api/hs/office/relations?relationType=REPRESENTATIVE&personData=" + uriEncoded("%{representativeFamilyName}"))
.expecting(OK).expecting(JSON).expectArrayElements(1),
path("[0].contact.caption").contains("%{representativeGivenName} %{representativeFamilyName}")
);
}
}

View File

@ -0,0 +1,86 @@
package net.hostsharing.hsadminng.hs.office.scenarios.partner;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class CreatePartner extends UseCase<CreatePartner> {
public CreatePartner(final ScenarioTest testSuite, final String resultAlias) {
super(testSuite, resultAlias);
}
public CreatePartner(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Person: Hostsharing eG", () ->
httpGet("/api/hs/office/persons?name=Hostsharing+eG")
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"Even in production data we expect this query to return just a single result." // TODO.impl: add constraint?
);
obtain("Person: %{%{tradeName???}???%{givenName???} %{familyName???}}", () ->
httpPost("/api/hs/office/persons", usingJsonBody("""
{
"personType": ${personType???},
"tradeName": ${tradeName???},
"givenName": ${givenName???},
"familyName": ${familyName???}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
);
obtain("Contact: %{contactCaption}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": ${contactCaption},
"postalAddress": {
%{postalAddress???}
},
"phoneNumbers": {
"office": ${officePhoneNumber???}
},
"emailAddresses": {
"main": ${emailAddress???}
}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
);
return httpPost("/api/hs/office/partners", usingJsonBody("""
{
"partnerNumber": ${partnerNumber},
"partnerRel": {
"anchorUuid": ${Person: Hostsharing eG},
"holderUuid": ${Person: %{%{tradeName???}???%{givenName???} %{familyName???}}},
"contactUuid": ${Contact: %{contactCaption}}
},
"details": {
"registrationOffice": "Registergericht Hamburg",
"registrationNumber": "1234567"
}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON);
}
@Override
protected void verify() {
verify(
"Verify the New Partner Relation",
() -> httpGet("/api/hs/office/relations?relationType=PARTNER&contactData=&{contactCaption}")
.expecting(OK).expecting(JSON).expectArrayElements(1)
);
}
}

View File

@ -0,0 +1,27 @@
package net.hostsharing.hsadminng.hs.office.scenarios.partner;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import org.springframework.http.HttpStatus;
public class DeletePartner extends UseCase<DeletePartner> {
public DeletePartner(final ScenarioTest testSuite) {
super(testSuite);
requires("Partner: Delete AG", alias -> new CreatePartner(testSuite, alias)
.given("personType", "LEGAL_PERSON")
.given("tradeName", "Delete AG")
.given("contactCaption", "Delete AG - Board of Directors")
.given("emailAddress", "board-of-directors@delete-ag.example.org"));
}
@Override
protected HttpResponse run() {
withTitle("Delete Partner by its UUID", () ->
httpDelete("/api/hs/office/partners/&{Partner: Delete AG}")
.expecting(HttpStatus.NO_CONTENT)
);
return null;
}
}

View File

@ -0,0 +1,27 @@
package net.hostsharing.hsadminng.hs.office.scenarios.person;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import org.springframework.http.HttpStatus;
public class CreatePerson extends UseCase<CreatePerson> {
public CreatePerson(final ScenarioTest testSuite, final String resultAlias) {
super(testSuite, resultAlias);
}
@Override
protected HttpResponse run() {
return withTitle("Create the Person", () ->
httpPost("/api/hs/office/persons", usingJsonBody("""
{
"personType": ${personType},
"tradeName": ${tradeName}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
);
}
}

View File

@ -0,0 +1,43 @@
package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.NO_CONTENT;
import static org.springframework.http.HttpStatus.OK;
public class RemoveOperationsContactFromPartner extends UseCase<RemoveOperationsContactFromPartner> {
public RemoveOperationsContactFromPartner(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Operations-Contact: %{operationsContactPerson}",
() ->
httpGet("/api/hs/office/relations?relationType=OPERATIONS&name=" + uriEncoded(
"%{operationsContactPerson}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
return withTitle("Delete the Contact", () ->
httpDelete("/api/hs/office/relations/&{Operations-Contact: %{operationsContactPerson}}")
.expecting(NO_CONTENT)
);
}
@Override
protected void verify() {
verify(
"Verify the New OPERATIONS Relation",
() -> httpGet("/api/hs/office/relations/&{Operations-Contact: %{operationsContactPerson}}")
.expecting(NOT_FOUND)
);
}
}

View File

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

View File

@ -0,0 +1,33 @@
package net.hostsharing.hsadminng.hs.office.scenarios.subscription;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.NO_CONTENT;
import static org.springframework.http.HttpStatus.OK;
public class UnsubscribeFromMailinglist extends UseCase<UnsubscribeFromMailinglist> {
public UnsubscribeFromMailinglist(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
obtain("Subscription: %{subscriberEMailAddress}", () ->
httpGet("/api/hs/office/relations?relationType=SUBSCRIBER" +
"&mark=" + uriEncoded("%{mailingList}") +
"&contactData=" + uriEncoded("%{subscriberEMailAddress}"))
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
);
return withTitle("Delete the Subscriber-Relation by its UUID", () ->
httpDelete("/api/hs/office/relations/&{Subscription: %{subscriberEMailAddress}}")
.expecting(NO_CONTENT)
);
}
}

View File

@ -8,7 +8,6 @@ import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountReposi
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.json.JSONException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
@ -58,7 +57,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCl
class ListSepaMandates {
@Test
void globalAdmin_canViewAllSepaMandates_ifNoCriteriaGiven() throws JSONException {
void globalAdmin_canViewAllSepaMandates_ifNoCriteriaGiven() {
RestAssured // @formatter:off
.given()
@ -97,6 +96,36 @@ class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCl
"""));
// @formatter:on
}
@Test
void globalAdmin_canFindSepaMandateByName() {
RestAssured // @formatter:off
.given()
.header("current-subject", "superuser-alex@hostsharing.net")
.port(port)
.when()
.get("http://localhost/api/hs/office/sepamandates?iban=DE02120300000000202051")
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.log().all()
.body("", lenientlyEquals("""
[
{
"debitor": { "debitorNumber": 1000111 },
"bankAccount": {
"iban": "DE02120300000000202051",
"holder": "First GmbH"
},
"reference": "ref-10001-11",
"validFrom": "2022-10-01",
"validTo": "2026-12-31"
}
]
"""));
// @formatter:on
}
}
@Nested

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