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

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

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

@ -1,10 +1,14 @@
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.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.InvalidateSepaMandateForDebitor;
import net.hostsharing.hsadminng.hs.office.scenarios.membership.CreateMembership;
@ -43,14 +47,39 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Test
@Order(1010)
@Produces(explicitly = "Partner: Test AG", implicitly = {"Person: Test AG", "Contact: Test AG - Board of Directors"})
void shouldCreatePartner() {
@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 - Board of Directors")
.given("emailAddress", "board-of-directors@test-ag.example.org")
.given("contactCaption", "Test AG - Hamburg")
.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()
.keep();
}
@ -106,6 +135,53 @@ class HsOfficeScenarioTests extends ScenarioTest {
.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
@Order(2010)
@Requires("Partner: Test AG")
@ -173,10 +249,18 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Produces("SEPA-Mandate: Test AG")
void shouldCreateSepaMandateForDebitor() {
new CreateSepaMandateForDebitor(this)
.given("debitor", "Test AG")
.given("memberNumberSuffix", "00")
.given("validFrom", "2024-10-15")
.given("membershipFeeBillable", "true")
// 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();
}
@ -186,17 +270,17 @@ class HsOfficeScenarioTests extends ScenarioTest {
@Requires("SEPA-Mandate: Test AG")
void shouldInvalidateSepaMandateForDebitor() {
new InvalidateSepaMandateForDebitor(this)
.given("sepaMandateUuid", "%{SEPA-Mandate: Test AG}")
.given("validUntil", "2025-09-30")
.given("bankAccountIBAN", "DE02701500000000594937")
.given("mandateValidUntil", "2025-09-30")
.doRun();
}
@Test
@Order(3109)
@Requires("SEPA-Mandate: Test AG")
void shouldDeleteSepaMandateForDebitor() {
new DeleteSepaMandateForDebitor(this)
.given("sepaMandateUuid", "%{SEPA-Mandate: Test AG}")
void shouldFinallyDeleteSepaMandateForDebitor() {
new FinallyDeleteSepaMandateForDebitor(this)
.given("bankAccountIBAN", "DE02701500000000594937")
.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
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.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;
@ -35,7 +36,7 @@ public abstract class ScenarioTest extends ContextBasedTest {
@Override
public String toString() {
return uuid.toString();
return ObjectUtils.toString(uuid);
}
}
@ -68,7 +69,6 @@ public abstract class ScenarioTest extends ContextBasedTest {
@AfterEach
void cleanup() { // final TestInfo testInfo
properties.clear();
// FIXME: Delete all aliases as well to force HTTP GET queries in each scenario?
testReport.close();
}

View File

@ -1,9 +1,57 @@
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();
@ -15,18 +63,41 @@ public class TemplateResolver {
}
String resolve() {
copy();
return resolved.toString();
final var resolved = copy();
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()) {
if ((currentChar() == '$' || currentChar() == '%') && nextChar() == '{') {
if (PlaceholderPrefix.contains(currentChar()) && nextChar() == '{') {
startPlaceholder(currentChar());
} else {
resolved.append(fetchChar());
}
}
return resolved.toString();
}
private boolean hasMoreChars() {
@ -41,7 +112,7 @@ public class TemplateResolver {
if (currentChar() == '}') {
--nested;
placeholder.append(fetchChar());
} else if ((currentChar() == '$' || currentChar() == '%') && nextChar() == '{') {
} else if (PlaceholderPrefix.contains (currentChar()) && nextChar() == '{') {
++nested;
placeholder.append(fetchChar());
} else {
@ -50,20 +121,33 @@ public class TemplateResolver {
}
final var name = new TemplateResolver(placeholder.toString(), properties).resolve();
final var value = propVal(name);
if ( intro == '%') {
resolved.append(value);
} else {
resolved.append(optionallyQuoted(value));
}
resolved.append(
PlaceholderPrefix.ofPrefixChar(intro).convert(value)
);
skipChar('}');
}
private Object propVal(final String name) {
final var val = properties.get(name);
if (val == null) {
throw new IllegalStateException("Missing required property: " + name);
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;
}
return val;
}
private void skipChar(final char expectedChar) {
@ -104,35 +188,13 @@ public class TemplateResolver {
return template.charAt(position+1);
}
private static String optionallyQuoted(final Object value) {
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 + "\"";
};
}
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() {
markdownReport.close();
if (markdownReport != null) {
markdownReport.close();
}
}
private static Object orderNumber(final Method method) {

View File

@ -8,6 +8,7 @@ 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;
@ -19,17 +20,21 @@ 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;
@ -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 void verify() {
}
public final UseCase<T> given(final String propName, final Object propValue) {
givenProperties.put(propName, propValue);
ScenarioTest.putProperty(propName, propValue);
@ -101,26 +111,30 @@ public abstract class UseCase<T extends UseCase<?>> {
final Function<HttpResponse, String> extractor,
final String... extraInfo) {
withTitle(ScenarioTest.resolve(alias), () -> {
http.get().keep(extractor);
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), () -> {
http.get().keep();
final var response = http.get().keep();
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;
code.run();
final var response = code.get();
this.nextTitle = null;
return response;
}
@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()
.GET()
.uri(new URI("http://localhost:" + testSuite.port + uriPath))
@ -132,7 +146,8 @@ public abstract class UseCase<T extends UseCase<?>> {
}
@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 request = HttpRequest.newBuilder()
.POST(BodyPublishers.ofString(requestBody))
@ -146,7 +161,8 @@ public abstract class UseCase<T extends UseCase<?>> {
}
@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 request = HttpRequest.newBuilder()
.method(HttpMethod.PATCH.toString(), BodyPublishers.ofString(requestBody))
@ -160,7 +176,8 @@ public abstract class UseCase<T extends UseCase<?>> {
}
@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()
.DELETE()
.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);
}
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));
return encode(ScenarioTest.resolve(text), StandardCharsets.UTF_8);
}
public static class JsonTemplate {
@ -193,7 +225,7 @@ public abstract class UseCase<T extends UseCase<?>> {
}
}
public class HttpResponse {
public final class HttpResponse {
@Getter
private final java.net.http.HttpResponse<String> response;
@ -232,7 +264,7 @@ public abstract class UseCase<T extends UseCase<?>> {
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;
assertThat(alias).as("cannot keep result, no alias found").isNotNull();
@ -240,14 +272,16 @@ public abstract class UseCase<T extends UseCase<?>> {
ScenarioTest.putAlias(
alias,
new ScenarioTest.Alias<>(UseCase.this.getClass(), UUID.fromString(value)));
return this;
}
public void keep() {
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
@ -263,7 +297,21 @@ public abstract class UseCase<T extends UseCase<?>> {
@SneakyThrows
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
@ -274,6 +322,8 @@ public abstract class UseCase<T extends UseCase<?>> {
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

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}"))
.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."
"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", () ->

View File

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