feature/use-case-acceptance-tests-2 (#117)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #117
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig 2024-11-05 13:58:31 +01:00
parent 3b94f117fb
commit 63af33d003
36 changed files with 1008 additions and 140 deletions

36
Jenkinsfile vendored
View File

@ -26,9 +26,35 @@ pipeline {
} }
} }
stage ('Compile & Test') { stage ('Compile') {
steps { 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' sourcePattern: 'src/main/java'
) )
// archive scenario-test reports in HTML format
sh '''
./gradlew convertMarkdownToHtml
'''
archiveArtifacts artifacts: 'doc/scenarios/*.html', allowEmptyArchive: true
// cleanup workspace // cleanup workspace
cleanWs() cleanWs()
} }

View File

@ -255,7 +255,7 @@ test {
'net.hostsharing.hsadminng.**.generated.**', 'net.hostsharing.hsadminng.**.generated.**',
] ]
useJUnitPlatform { useJUnitPlatform {
excludeTags 'import' excludeTags 'importOfficeData', 'importHostingData', 'scenarioTest'
} }
} }
jacocoTestReport { jacocoTestReport {
@ -344,6 +344,17 @@ tasks.register('importHostingAssets', Test) {
mustRunAfter spotlessJava mustRunAfter spotlessJava
} }
tasks.register('scenarioTests', Test) {
useJUnitPlatform {
includeTags 'scenarioTest'
}
group 'verification'
description 'run the import jobs as tests'
mustRunAfter spotlessJava
}
// pitest mutation testing // pitest mutation testing
pitest { pitest {
targetClasses = ['net.hostsharing.hsadminng.**'] targetClasses = ['net.hostsharing.hsadminng.**']
@ -391,3 +402,45 @@ tasks.named("dependencyUpdates").configure {
isNonStable(it.candidate.version) 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

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

View File

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

View File

@ -1,10 +1,14 @@
package net.hostsharing.hsadminng.hs.office.scenarios; package net.hostsharing.hsadminng.hs.office.scenarios;
import net.hostsharing.hsadminng.HsadminNgApplication; 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.CreateExternalDebitorForPartner;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.CreateSelfDebitorForPartner; 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.CreateSepaMandateForDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.DeleteSepaMandateForDebitor; 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.DontDeleteDefaultDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.debitor.InvalidateSepaMandateForDebitor; 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.membership.CreateMembership;
@ -43,14 +47,39 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Test @Test
@Order(1010) @Order(1010)
@Produces(explicitly = "Partner: Test AG", implicitly = {"Person: Test AG", "Contact: Test AG - Board of Directors"}) @Produces(explicitly = "Partner: Test AG", implicitly = {"Person: Test AG", "Contact: Test AG - Hamburg"})
void shouldCreatePartner() { void shouldCreateLegalPersonAsPartner() {
new CreatePartner(this) new CreatePartner(this)
.given("partnerNumber", 31010) .given("partnerNumber", 31010)
.given("personType", "LEGAL_PERSON") .given("personType", "LEGAL_PERSON")
.given("tradeName", "Test AG") .given("tradeName", "Test AG")
.given("contactCaption", "Test AG - Board of Directors") .given("contactCaption", "Test AG - Hamburg")
.given("emailAddress", "board-of-directors@test-ag.example.org") .given("postalAddress", """
Shanghai-Allee 1
20123 Hamburg
""")
.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", """
An der Wandse 34
22123 Hamburg
""")
.given("officePhoneNumber", "+49 40 123456")
.given("emailAddress", "michelle.matthieu@example.org")
.doRun() .doRun()
.keep(); .keep();
} }
@ -106,6 +135,53 @@ class HsOfficeScenarioTests extends ScenarioTest {
.doRun(); .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 - Norden")
.given("newPostalAddress", """
Am Hafen 11
26506 Norden
""")
.given("newOfficePhoneNumber", "+49 4931 654321-0")
.given("newEmailAddress", "norden@test-ag.example.org")
.doRun();
}
@Test @Test
@Order(2010) @Order(2010)
@Requires("Partner: Test AG") @Requires("Partner: Test AG")
@ -173,10 +249,18 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Produces("SEPA-Mandate: Test AG") @Produces("SEPA-Mandate: Test AG")
void shouldCreateSepaMandateForDebitor() { void shouldCreateSepaMandateForDebitor() {
new CreateSepaMandateForDebitor(this) new CreateSepaMandateForDebitor(this)
.given("debitor", "Test AG") // existing debitor
.given("memberNumberSuffix", "00") .given("debitorNumber", "3101000")
.given("validFrom", "2024-10-15")
.given("membershipFeeBillable", "true") // 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() .doRun()
.keep(); .keep();
} }
@ -186,17 +270,17 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Requires("SEPA-Mandate: Test AG") @Requires("SEPA-Mandate: Test AG")
void shouldInvalidateSepaMandateForDebitor() { void shouldInvalidateSepaMandateForDebitor() {
new InvalidateSepaMandateForDebitor(this) new InvalidateSepaMandateForDebitor(this)
.given("sepaMandateUuid", "%{SEPA-Mandate: Test AG}") .given("bankAccountIBAN", "DE02701500000000594937")
.given("validUntil", "2025-09-30") .given("mandateValidUntil", "2025-09-30")
.doRun(); .doRun();
} }
@Test @Test
@Order(3109) @Order(3109)
@Requires("SEPA-Mandate: Test AG") @Requires("SEPA-Mandate: Test AG")
void shouldDeleteSepaMandateForDebitor() { void shouldFinallyDeleteSepaMandateForDebitor() {
new DeleteSepaMandateForDebitor(this) new FinallyDeleteSepaMandateForDebitor(this)
.given("sepaMandateUuid", "%{SEPA-Mandate: Test AG}") .given("bankAccountIBAN", "DE02701500000000594937")
.doRun(); .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

@ -63,4 +63,34 @@ Here, use-cases can be re-used, usually with different data.
### The Use-Case Itself ### The Use-Case Itself
The use-case 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

@ -12,6 +12,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestInfo;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.boot.test.web.server.LocalServerPort;
import org.testcontainers.shaded.org.apache.commons.lang3.ObjectUtils;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.HashMap; import java.util.HashMap;
@ -35,7 +36,7 @@ public abstract class ScenarioTest extends ContextBasedTest {
@Override @Override
public String toString() { public String toString() {
return uuid.toString(); return ObjectUtils.toString(uuid);
} }
} }
@ -68,7 +69,6 @@ public abstract class ScenarioTest extends ContextBasedTest {
@AfterEach @AfterEach
void cleanup() { // final TestInfo testInfo void cleanup() { // final TestInfo testInfo
properties.clear(); properties.clear();
// FIXME: Delete all aliases as well to force HTTP GET queries in each scenario?
testReport.close(); testReport.close();
} }

View File

@ -1,9 +1,57 @@
package net.hostsharing.hsadminng.hs.office.scenarios; 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.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class TemplateResolver { 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 String template;
private final Map<String, Object> properties; private final Map<String, Object> properties;
private final StringBuilder resolved = new StringBuilder(); private final StringBuilder resolved = new StringBuilder();
@ -15,18 +63,41 @@ public class TemplateResolver {
} }
String resolve() { String resolve() {
copy(); final var resolved = copy();
return resolved.toString(); final var withoutDroppedLines = dropLinesWithNullProperties(resolved);
final var result = removeDanglingCommas(withoutDroppedLines);
return result;
} }
private void copy() { 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()) { while (hasMoreChars()) {
if ((currentChar() == '$' || currentChar() == '%') && nextChar() == '{') { if (PlaceholderPrefix.contains(currentChar()) && nextChar() == '{') {
startPlaceholder(currentChar()); startPlaceholder(currentChar());
} else { } else {
resolved.append(fetchChar()); resolved.append(fetchChar());
} }
} }
return resolved.toString();
} }
private boolean hasMoreChars() { private boolean hasMoreChars() {
@ -41,7 +112,7 @@ public class TemplateResolver {
if (currentChar() == '}') { if (currentChar() == '}') {
--nested; --nested;
placeholder.append(fetchChar()); placeholder.append(fetchChar());
} else if ((currentChar() == '$' || currentChar() == '%') && nextChar() == '{') { } else if (PlaceholderPrefix.contains (currentChar()) && nextChar() == '{') {
++nested; ++nested;
placeholder.append(fetchChar()); placeholder.append(fetchChar());
} else { } else {
@ -50,20 +121,33 @@ public class TemplateResolver {
} }
final var name = new TemplateResolver(placeholder.toString(), properties).resolve(); final var name = new TemplateResolver(placeholder.toString(), properties).resolve();
final var value = propVal(name); final var value = propVal(name);
if ( intro == '%') { resolved.append(
resolved.append(value); PlaceholderPrefix.ofPrefixChar(intro).convert(value)
} else { );
resolved.append(optionallyQuoted(value));
}
skipChar('}'); skipChar('}');
} }
private Object propVal(final String name) { private Object propVal(final String nameExpression) {
final var val = properties.get(name); if (nameExpression.endsWith(IF_NOT_FOUND_SYMBOL)) {
if (val == null) { final String pureName = nameExpression.substring(0, nameExpression.length() - IF_NOT_FOUND_SYMBOL.length());
throw new IllegalStateException("Missing required property: " + name); 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;
} }
return val;
} }
private void skipChar(final char expectedChar) { private void skipChar(final char expectedChar) {
@ -104,35 +188,13 @@ public class TemplateResolver {
return template.charAt(position+1); return template.charAt(position+1);
} }
private static String optionallyQuoted(final Object value) { private static String jsonQuoted(final Object value) {
return switch (value) { return switch (value) {
case null -> null;
case Boolean bool -> bool.toString(); case Boolean bool -> bool.toString();
case Number number -> number.toString(); case Number number -> number.toString();
case String string -> "\"" + string.replace("\n", "\\n") + "\""; case String string -> "\"" + string.replace("\n", "\\n") + "\"";
default -> "\"" + value + "\""; default -> "\"" + value + "\"";
}; };
} }
public static void main(String[] args) {
System.out.println(
new TemplateResolver("""
etwas davor,
${einfacher Platzhalter},
${verschachtelter %{Name}},
und nochmal ohne Quotes:
%{einfacher Platzhalter},
%{verschachtelter %{Name}},
etwas danach.
""",
Map.ofEntries(
Map.entry("Name", "placeholder"),
Map.entry("einfacher Platzhalter", "simple placeholder"),
Map.entry("verschachtelter placeholder", "nested placeholder")
)).resolve());
}
} }

View File

@ -0,0 +1,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

@ -57,7 +57,9 @@ public class TestReport {
} }
public void close() { public void close() {
markdownReport.close(); if (markdownReport != null) {
markdownReport.close();
}
} }
private static Object orderNumber(final Method method) { private static Object orderNumber(final Method method) {

View File

@ -8,6 +8,7 @@ import lombok.Getter;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import net.hostsharing.hsadminng.reflection.AnnotationFinder; import net.hostsharing.hsadminng.reflection.AnnotationFinder;
import org.apache.commons.collections4.map.LinkedMap; import org.apache.commons.collections4.map.LinkedMap;
import org.assertj.core.api.OptionalAssert;
import org.hibernate.AssertionFailure; import org.hibernate.AssertionFailure;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -19,17 +20,21 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers; import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse.BodyHandlers; import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
import static java.net.URLEncoder.encode; import static java.net.URLEncoder.encode;
import static org.assertj.core.api.Assertions.assertThat; 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.isBlank;
import static org.junit.platform.commons.util.StringUtils.isNotBlank; import static org.junit.platform.commons.util.StringUtils.isNotBlank;
@ -80,11 +85,16 @@ public abstract class UseCase<T extends UseCase<?>> {
} }
}) })
); );
return run(); final var response = run();
verify();
return response;
} }
protected abstract HttpResponse run(); protected abstract HttpResponse run();
protected void verify() {
}
public final UseCase<T> given(final String propName, final Object propValue) { public final UseCase<T> given(final String propName, final Object propValue) {
givenProperties.put(propName, propValue); givenProperties.put(propName, propValue);
ScenarioTest.putProperty(propName, propValue); ScenarioTest.putProperty(propName, propValue);
@ -101,26 +111,30 @@ public abstract class UseCase<T extends UseCase<?>> {
final Function<HttpResponse, String> extractor, final Function<HttpResponse, String> extractor,
final String... extraInfo) { final String... extraInfo) {
withTitle(ScenarioTest.resolve(alias), () -> { withTitle(ScenarioTest.resolve(alias), () -> {
http.get().keep(extractor); final var response = http.get().keep(extractor);
Arrays.stream(extraInfo).forEach(testReport::printPara); Arrays.stream(extraInfo).forEach(testReport::printPara);
return response;
}); });
} }
public final void obtain(final String alias, final Supplier<HttpResponse> http, final String... extraInfo) { public final void obtain(final String alias, final Supplier<HttpResponse> http, final String... extraInfo) {
withTitle(ScenarioTest.resolve(alias), () -> { withTitle(ScenarioTest.resolve(alias), () -> {
http.get().keep(); final var response = http.get().keep();
Arrays.stream(extraInfo).forEach(testReport::printPara); Arrays.stream(extraInfo).forEach(testReport::printPara);
return response;
}); });
} }
private void withTitle(final String title, final Runnable code) { public HttpResponse withTitle(final String title, final Supplier<HttpResponse> code) {
this.nextTitle = title; this.nextTitle = title;
code.run(); final var response = code.get();
this.nextTitle = null; this.nextTitle = null;
return response;
} }
@SneakyThrows @SneakyThrows
public final HttpResponse httpGet(final String uriPath) { public final HttpResponse httpGet(final String uriPathWithPlaceholders) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
final var request = HttpRequest.newBuilder() final var request = HttpRequest.newBuilder()
.GET() .GET()
.uri(new URI("http://localhost:" + testSuite.port + uriPath)) .uri(new URI("http://localhost:" + testSuite.port + uriPath))
@ -132,7 +146,8 @@ public abstract class UseCase<T extends UseCase<?>> {
} }
@SneakyThrows @SneakyThrows
public final HttpResponse httpPost(final String uriPath, final JsonTemplate bodyJsonTemplate) { public final HttpResponse httpPost(final String uriPathWithPlaceholders, final JsonTemplate bodyJsonTemplate) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
final var requestBody = bodyJsonTemplate.resolvePlaceholders(); final var requestBody = bodyJsonTemplate.resolvePlaceholders();
final var request = HttpRequest.newBuilder() final var request = HttpRequest.newBuilder()
.POST(BodyPublishers.ofString(requestBody)) .POST(BodyPublishers.ofString(requestBody))
@ -146,7 +161,8 @@ public abstract class UseCase<T extends UseCase<?>> {
} }
@SneakyThrows @SneakyThrows
public final HttpResponse httpPatch(final String uriPath, final JsonTemplate bodyJsonTemplate) { public final HttpResponse httpPatch(final String uriPathWithPlaceholders, final JsonTemplate bodyJsonTemplate) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
final var requestBody = bodyJsonTemplate.resolvePlaceholders(); final var requestBody = bodyJsonTemplate.resolvePlaceholders();
final var request = HttpRequest.newBuilder() final var request = HttpRequest.newBuilder()
.method(HttpMethod.PATCH.toString(), BodyPublishers.ofString(requestBody)) .method(HttpMethod.PATCH.toString(), BodyPublishers.ofString(requestBody))
@ -160,7 +176,8 @@ public abstract class UseCase<T extends UseCase<?>> {
} }
@SneakyThrows @SneakyThrows
public final HttpResponse httpDelete(final String uriPath) { public final HttpResponse httpDelete(final String uriPathWithPlaceholders) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders);
final var request = HttpRequest.newBuilder() final var request = HttpRequest.newBuilder()
.DELETE() .DELETE()
.uri(new URI("http://localhost:" + testSuite.port + uriPath)) .uri(new URI("http://localhost:" + testSuite.port + uriPath))
@ -172,12 +189,27 @@ public abstract class UseCase<T extends UseCase<?>> {
return new HttpResponse(HttpMethod.DELETE, uriPath, null, response); 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) { public final UUID uuid(final String alias) {
return ScenarioTest.uuid(alias); return ScenarioTest.uuid(alias);
} }
public String uriEncoded(final String text) { public String uriEncoded(final String text) {
return encode(ScenarioTest.resolve(text)); return encode(ScenarioTest.resolve(text), StandardCharsets.UTF_8);
} }
public static class JsonTemplate { public static class JsonTemplate {
@ -193,7 +225,7 @@ public abstract class UseCase<T extends UseCase<?>> {
} }
} }
public class HttpResponse { public final class HttpResponse {
@Getter @Getter
private final java.net.http.HttpResponse<String> response; private final java.net.http.HttpResponse<String> response;
@ -232,7 +264,7 @@ public abstract class UseCase<T extends UseCase<?>> {
return this; return this;
} }
public void keep(final Function<HttpResponse, String> extractor) { public HttpResponse keep(final Function<HttpResponse, String> extractor) {
final var alias = nextTitle != null ? nextTitle : resultAlias; final var alias = nextTitle != null ? nextTitle : resultAlias;
assertThat(alias).as("cannot keep result, no alias found").isNotNull(); assertThat(alias).as("cannot keep result, no alias found").isNotNull();
@ -240,14 +272,16 @@ public abstract class UseCase<T extends UseCase<?>> {
ScenarioTest.putAlias( ScenarioTest.putAlias(
alias, alias,
new ScenarioTest.Alias<>(UseCase.this.getClass(), UUID.fromString(value))); new ScenarioTest.Alias<>(UseCase.this.getClass(), UUID.fromString(value)));
return this;
} }
public void keep() { public HttpResponse keep() {
final var alias = nextTitle != null ? nextTitle : resultAlias; final var alias = nextTitle != null ? nextTitle : resultAlias;
assertThat(alias).as("cannot keep result, no alias found").isNotNull(); assertThat(alias).as("cannot keep result, no alias found").isNotNull();
ScenarioTest.putAlias( ScenarioTest.putAlias(
alias, alias,
new ScenarioTest.Alias<>(UseCase.this.getClass(), locationUuid)); new ScenarioTest.Alias<>(UseCase.this.getClass(), locationUuid));
return this;
} }
@SneakyThrows @SneakyThrows
@ -263,7 +297,21 @@ public abstract class UseCase<T extends UseCase<?>> {
@SneakyThrows @SneakyThrows
public String getFromBody(final String path) { public String getFromBody(final String path) {
return JsonPath.parse(response.body()).read(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 @SneakyThrows
@ -274,6 +322,8 @@ public abstract class UseCase<T extends UseCase<?>> {
testReport.printLine("\n### " + nextTitle + "\n"); testReport.printLine("\n### " + nextTitle + "\n");
} else if (resultAlias != null) { } else if (resultAlias != null) {
testReport.printLine("\n### " + resultAlias + "\n"); testReport.printLine("\n### " + resultAlias + "\n");
} else {
fail("please wrap the http...-call in the UseCase using `withTitle(...)`");
} }
// the request // the request

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,44 @@
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,64 @@
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."
);
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

@ -26,7 +26,7 @@ public class CreateExternalDebitorForPartner extends UseCase<CreateExternalDebit
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}")) httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"), 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." "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", () -> obtain("BankAccount: Billing GmbH - refund bank account", () ->

View File

@ -19,8 +19,7 @@ public class CreateSelfDebitorForPartner extends UseCase<CreateSelfDebitorForPar
httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerPersonTradeName}")) httpGet("/api/hs/office/relations?relationType=PARTNER&personData=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].holder.uuid"), 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.", "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
"**HINT**: With production data, you might get multiple results and have to decide which is the right one."
); );
obtain("BankAccount: Test AG - refund bank account", () -> obtain("BankAccount: Test AG - refund bank account", () ->

View File

@ -5,6 +5,7 @@ import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON; import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
public class CreateSepaMandateForDebitor extends UseCase<CreateSepaMandateForDebitor> { public class CreateSepaMandateForDebitor extends UseCase<CreateSepaMandateForDebitor> {
@ -14,12 +15,19 @@ public class CreateSepaMandateForDebitor extends UseCase<CreateSepaMandateForDeb
@Override @Override
protected HttpResponse run() { 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", () -> obtain("BankAccount: Test AG - debit bank account", () ->
httpPost("/api/hs/office/bankaccounts", usingJsonBody(""" httpPost("/api/hs/office/bankaccounts", usingJsonBody("""
{ {
"holder": "Test AG - debit bank account", "holder": ${bankAccountHolder},
"iban": "DE02701500000000594937", "iban": ${bankAccountIBAN},
"bic": "SSKMDEMM" "bic": ${bankAccountBIC}
} }
""")) """))
.expecting(CREATED).expecting(JSON) .expecting(CREATED).expecting(JSON)
@ -29,9 +37,9 @@ public class CreateSepaMandateForDebitor extends UseCase<CreateSepaMandateForDeb
{ {
"debitorUuid": ${Debitor: Test AG - main debitor}, "debitorUuid": ${Debitor: Test AG - main debitor},
"bankAccountUuid": ${BankAccount: Test AG - debit bank account}, "bankAccountUuid": ${BankAccount: Test AG - debit bank account},
"reference": "Test AG - main debitor", "reference": ${mandateReference},
"agreement": "2022-10-12", "agreement": ${mandateAgreement},
"validFrom": "2022-10-13" "validFrom": ${mandateValidFrom}
} }
""")) """))
.expecting(CREATED).expecting(JSON); .expecting(CREATED).expecting(JSON);

View File

@ -24,8 +24,10 @@ public class DeleteDebitor extends UseCase<DeleteDebitor> {
@Override @Override
protected HttpResponse run() { protected HttpResponse run() {
httpDelete("/api/hs/office/debitors/" + uuid("Debitor: Test AG - delete debitor")) withTitle("Delete the Debitor using its UUID", () ->
.expecting(HttpStatus.NO_CONTENT); httpDelete("/api/hs/office/debitors/&{Debitor: Test AG - delete debitor}")
.expecting(HttpStatus.NO_CONTENT)
);
return null; return null;
} }
} }

View File

@ -1,20 +0,0 @@
package net.hostsharing.hsadminng.hs.office.scenarios.debitor;
import net.hostsharing.hsadminng.hs.office.scenarios.UseCase;
import net.hostsharing.hsadminng.hs.office.scenarios.ScenarioTest;
import org.springframework.http.HttpStatus;
public class DeleteSepaMandateForDebitor extends UseCase<DeleteSepaMandateForDebitor> {
public DeleteSepaMandateForDebitor(final ScenarioTest testSuite) {
super(testSuite);
}
@Override
protected HttpResponse run() {
httpDelete("/api/hs/office/sepamandates/" + uuid("SEPA-Mandate: Test AG"))
.expecting(HttpStatus.NO_CONTENT);
return null;
}
}

View File

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

@ -15,11 +15,20 @@ public class InvalidateSepaMandateForDebitor extends UseCase<InvalidateSepaManda
@Override @Override
protected HttpResponse run() { protected HttpResponse run() {
return httpPatch("/api/hs/office/sepamandates/" + uuid("SEPA-Mandate: Test AG"), usingJsonBody(""" 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": ${validUntil} "validUntil": ${mandateValidUntil}
} }
""")) """))
.expecting(OK).expecting(JSON); .expecting(OK).expecting(JSON)
);
} }
} }

View File

@ -22,7 +22,7 @@ public class AddOperationsContactToPartner extends UseCase<AddOperationsContactT
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}")) httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"), 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." "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}", () -> obtain("Person: %{operationsContactGivenName} %{operationsContactFamilyName}", () ->
@ -64,4 +64,15 @@ public class AddOperationsContactToPartner extends UseCase<AddOperationsContactT
""")) """))
.expecting(CREATED).expecting(JSON); .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

@ -22,7 +22,7 @@ public class AddRepresentativeToPartner extends UseCase<AddRepresentativeToPartn
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}")) httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"), 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." "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}", () -> obtain("Person: %{representativeGivenName} %{representativeFamilyName}", () ->
@ -65,4 +65,14 @@ public class AddRepresentativeToPartner extends UseCase<AddRepresentativeToPartn
""")) """))
.expecting(CREATED).expecting(JSON); .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

@ -28,22 +28,28 @@ public class CreatePartner extends UseCase<CreatePartner> {
"Even in production data we expect this query to return just a single result." // TODO.impl: add constraint? "Even in production data we expect this query to return just a single result." // TODO.impl: add constraint?
); );
obtain("Person: %{tradeName}", () -> obtain("Person: %{%{tradeName???}???%{givenName???} %{familyName???}}", () ->
httpPost("/api/hs/office/persons", usingJsonBody(""" httpPost("/api/hs/office/persons", usingJsonBody("""
{ {
"personType": ${personType}, "personType": ${personType???},
"tradeName": ${tradeName} "tradeName": ${tradeName???},
"givenName": ${givenName???},
"familyName": ${familyName???}
} }
""")) """))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON) .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
); );
obtain("Contact: %{tradeName} - Board of Directors", () -> obtain("Contact: %{contactCaption}", () ->
httpPost("/api/hs/office/contacts", usingJsonBody(""" httpPost("/api/hs/office/contacts", usingJsonBody("""
{ {
"caption": ${contactCaption}, "caption": ${contactCaption},
"postalAddress": ${postalAddress???},
"phoneNumbers": {
"office": ${officePhoneNumber???}
},
"emailAddresses": { "emailAddresses": {
"main": ${emailAddress} "main": ${emailAddress???}
} }
} }
""")) """))
@ -55,8 +61,8 @@ public class CreatePartner extends UseCase<CreatePartner> {
"partnerNumber": ${partnerNumber}, "partnerNumber": ${partnerNumber},
"partnerRel": { "partnerRel": {
"anchorUuid": ${Person: Hostsharing eG}, "anchorUuid": ${Person: Hostsharing eG},
"holderUuid": ${Person: %{tradeName}}, "holderUuid": ${Person: %{%{tradeName???}???%{givenName???} %{familyName???}}},
"contactUuid": ${Contact: %{tradeName} - Board of Directors} "contactUuid": ${Contact: %{contactCaption}}
}, },
"details": { "details": {
"registrationOffice": "Registergericht Hamburg", "registrationOffice": "Registergericht Hamburg",
@ -66,4 +72,13 @@ public class CreatePartner extends UseCase<CreatePartner> {
""")) """))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON); .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

@ -18,8 +18,10 @@ public class DeletePartner extends UseCase<DeletePartner> {
@Override @Override
protected HttpResponse run() { protected HttpResponse run() {
httpDelete("/api/hs/office/partners/" + uuid("Partner: Delete AG")) withTitle("Delete Partner by its UUID", () ->
.expecting(HttpStatus.NO_CONTENT); httpDelete("/api/hs/office/partners/&{Partner: Delete AG}")
.expecting(HttpStatus.NO_CONTENT)
);
return null; return null;
} }
} }

View File

@ -14,12 +14,14 @@ public class CreatePerson extends UseCase<CreatePerson> {
@Override @Override
protected HttpResponse run() { protected HttpResponse run() {
return httpPost("/api/hs/office/persons", usingJsonBody(""" return withTitle("Create the Person", () ->
httpPost("/api/hs/office/persons", usingJsonBody("""
{ {
"personType": ${personType}, "personType": ${personType},
"tradeName": ${tradeName} "tradeName": ${tradeName}
} }
""")) """))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON); .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
);
} }
} }

View File

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

@ -22,7 +22,7 @@ public class SubscribeToMailinglist extends UseCase<SubscribeToMailinglist> {
httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}")) httpGet("/api/hs/office/persons?name=" + uriEncoded("%{partnerPersonTradeName}"))
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"), 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." "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}", () -> obtain("Person: %{subscriberGivenName} %{subscriberFamilyName}", () ->

View File

@ -22,10 +22,12 @@ public class UnsubscribeFromMailinglist extends UseCase<UnsubscribeFromMailingli
"&contactData=" + uriEncoded("%{subscriberEMailAddress}")) "&contactData=" + uriEncoded("%{subscriberEMailAddress}"))
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"), 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." "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one."
); );
return httpDelete("/api/hs/office/relations/" + uuid("Subscription: %{subscriberEMailAddress}")) return withTitle("Delete the Subscriber-Relation by its UUID", () ->
.expecting(NO_CONTENT); 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.hs.office.debitor.HsOfficeDebitorRepository;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.json.JSONException;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@ -58,7 +57,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCl
class ListSepaMandates { class ListSepaMandates {
@Test @Test
void globalAdmin_canViewAllSepaMandates_ifNoCriteriaGiven() throws JSONException { void globalAdmin_canViewAllSepaMandates_ifNoCriteriaGiven() {
RestAssured // @formatter:off RestAssured // @formatter:off
.given() .given()
@ -97,6 +96,36 @@ class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCl
""")); """));
// @formatter:on // @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 @Nested