diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74dc748 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +*.csv diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..f9ca841 --- /dev/null +++ b/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'org.springframework.boot' version '2.4.5' + id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'java' + id 'groovy' +} + +group = 'pl.programistazacny' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.mapstruct:mapstruct:1.4.2.Final' + implementation 'com.opencsv:opencsv:5.4' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.h2database:h2:1.4.200' + //implementation 'org.zalando:logbook-spring-boot-starter:2.6.2' conflict with H2 console + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.spockframework:spock-core:2.0-M5-groovy-3.0' + testImplementation 'org.spockframework:spock-spring:2.0-M5-groovy-3.0' + testImplementation 'org.codehaus.groovy:groovy-all:3.0.7' + testImplementation 'com.tngtech.archunit:archunit:0.18.0' +} + +ext['groovy.version'] = '3.0.7' + +test { + useJUnitPlatform() + + afterTest { desc, result -> + logger.quiet "Executing test \"${desc.name}\" [${desc.className}] with result: ${result.resultType} and took: ${result.endTime - result.startTime}ms" + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..442d913 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..db8eb02 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'java-developer-exercise' diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/PaymentController.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/PaymentController.java new file mode 100644 index 0000000..0b27e8c --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/PaymentController.java @@ -0,0 +1,68 @@ +package pl.programistazacny.javadeveloperexercise.adapter.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import pl.programistazacny.javadeveloperexercise.adapter.controller.mapper.PaymentControllerMapper; +import pl.programistazacny.javadeveloperexercise.adapter.controller.model.PaymentRequest; +import pl.programistazacny.javadeveloperexercise.adapter.controller.model.PaymentResponse; +import pl.programistazacny.javadeveloperexercise.domain.PaymentService; +import pl.programistazacny.javadeveloperexercise.domain.model.Payment; + +import javax.validation.Valid; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@RestController +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + + @GetMapping("/payment/{id}") + @ResponseBody + ResponseEntity get(@PathVariable UUID id) { + return ResponseEntity.of(paymentService.get(id).map(payment -> PaymentControllerMapper.INSTANCE.domainToResponse(payment))); + } + + @GetMapping("/payments") + @ResponseBody + ResponseEntity> getAll() { + return ResponseEntity + .status(HttpStatus.OK) + .body( + paymentService.getAll().stream() + .map(payment -> PaymentControllerMapper.INSTANCE.domainToResponse(payment)) + .collect(Collectors.toUnmodifiableList()) + ); + } + + @PostMapping("/payment") + ResponseEntity create(@RequestBody @Valid PaymentRequest paymentRequest) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + PaymentControllerMapper.INSTANCE.domainToResponse( + paymentService.add(PaymentControllerMapper.INSTANCE.requestToDto(paymentRequest)) + ) + ); + } + + @PutMapping("/payment/{id}") + ResponseEntity edit(@PathVariable UUID id, @RequestBody @Valid PaymentRequest paymentRequest) { + Optional paymentEdited = paymentService.edit(id, PaymentControllerMapper.INSTANCE.requestToDto(paymentRequest)); + return ResponseEntity + .status(paymentEdited.isPresent() ? HttpStatus.OK : HttpStatus.NOT_FOUND) + .body(paymentEdited.isPresent() ? PaymentControllerMapper.INSTANCE.domainToResponse(paymentEdited.get()) : null); + } + + @DeleteMapping("/payment/{id}") + ResponseEntity delete(@PathVariable UUID id) { + return ResponseEntity + .status(paymentService.delete(id) ? HttpStatus.NO_CONTENT : HttpStatus.NOT_FOUND) + .build(); + } +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/mapper/PaymentControllerMapper.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/mapper/PaymentControllerMapper.java new file mode 100644 index 0000000..339da1f --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/mapper/PaymentControllerMapper.java @@ -0,0 +1,17 @@ +package pl.programistazacny.javadeveloperexercise.adapter.controller.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; +import pl.programistazacny.javadeveloperexercise.adapter.controller.model.PaymentRequest; +import pl.programistazacny.javadeveloperexercise.adapter.controller.model.PaymentResponse; +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto; +import pl.programistazacny.javadeveloperexercise.domain.model.Payment; + +@Mapper +public interface PaymentControllerMapper { + PaymentControllerMapper INSTANCE = Mappers.getMapper(PaymentControllerMapper.class); + + PaymentDto requestToDto(PaymentRequest paymentRequest); + + PaymentResponse domainToResponse(Payment payment); +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/model/PaymentRequest.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/model/PaymentRequest.java new file mode 100644 index 0000000..d964482 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/model/PaymentRequest.java @@ -0,0 +1,20 @@ +package pl.programistazacny.javadeveloperexercise.adapter.controller.model; + +import lombok.Value; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.util.UUID; + +@Value +public class PaymentRequest { + @NotNull + private final BigDecimal amount; + @NotNull + private final String currency; + @NotNull + private final UUID userId; + @NotBlank + private final String targetAccountNumber; +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/model/PaymentResponse.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/model/PaymentResponse.java new file mode 100644 index 0000000..cb24d8c --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/controller/model/PaymentResponse.java @@ -0,0 +1,15 @@ +package pl.programistazacny.javadeveloperexercise.adapter.controller.model; + +import lombok.Value; + +import java.math.BigDecimal; +import java.util.UUID; + +@Value +public class PaymentResponse { + private UUID id; + private BigDecimal amount; + private String currency; + private UUID userId; + private String targetAccountNumber; +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/PaymentCsvRepository.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/PaymentCsvRepository.java new file mode 100644 index 0000000..8f56e1f --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/PaymentCsvRepository.java @@ -0,0 +1,103 @@ +package pl.programistazacny.javadeveloperexercise.adapter.csv; + +import com.opencsv.bean.CsvToBeanBuilder; +import com.opencsv.bean.StatefulBeanToCsv; +import com.opencsv.bean.StatefulBeanToCsvBuilder; +import com.opencsv.exceptions.CsvDataTypeMismatchException; +import com.opencsv.exceptions.CsvRequiredFieldEmptyException; +import lombok.RequiredArgsConstructor; +import pl.programistazacny.javadeveloperexercise.adapter.csv.mapper.PaymentCsvMapper; +import pl.programistazacny.javadeveloperexercise.adapter.csv.model.PaymentCsv; +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto; +import pl.programistazacny.javadeveloperexercise.domain.model.Payment; +import pl.programistazacny.javadeveloperexercise.domain.port.PaymentRepository; + +import java.io.*; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class PaymentCsvRepository implements PaymentRepository { + + private final String csvFilename; + + @Override + public Optional findById(UUID id) { + return findByIdRaw(id).map(paymentCsv -> PaymentCsvMapper.INSTANCE.csvToDomain(paymentCsv)); + } + + private Optional findByIdRaw(UUID id) { + return findAllRaw().stream() + .filter(payment -> payment.getId().equals(id)) + .reduce((p1, p2) -> { + throw new IllegalStateException("Multiple elements: " + p1 + ", " + p2); + }); + } + + @Override + public List findAll() { + return findAllRaw().stream() + .map(paymentCsv -> PaymentCsvMapper.INSTANCE.csvToDomain(paymentCsv)) + .collect(Collectors.toList()); + } + + private List findAllRaw() { + List payments; + try { + payments = new CsvToBeanBuilder(new FileReader(csvFilename)) + .withType(PaymentCsv.class) + .build() + .parse(); + } catch (FileNotFoundException e) { + throw new RuntimeException("Problem with Payments csv file!", e); + } + return payments; + } + + @Override + public Payment create(PaymentDto paymentDto) { + PaymentCsv paymentCsv = PaymentCsvMapper.INSTANCE.dtoToCsv(paymentDto); + paymentCsv.setId(UUID.randomUUID()); + + write(Arrays.asList(paymentCsv), true); + + return PaymentCsvMapper.INSTANCE.csvToDomain(paymentCsv); + } + + private void write(List paymentsCsv, boolean append) { + try (Writer writer = new FileWriter(csvFilename, append)) { + StatefulBeanToCsv beanToCsv = new StatefulBeanToCsvBuilder(writer).build(); + beanToCsv.write(paymentsCsv); + } catch (IOException | CsvRequiredFieldEmptyException | CsvDataTypeMismatchException e) { + throw new RuntimeException("Problem with writing to csv file!", e); + } + } + + @Override + public Optional update(UUID id, PaymentDto paymentDto) { + List paymentsCsv = findAllRaw(); + Optional paymentsCsvToUpdate = paymentsCsv.stream() + .filter(payment -> payment.getId().equals(id)) + .reduce((p1, p2) -> { + throw new IllegalStateException("Multiple elements: " + p1 + ", " + p2); + }); + return paymentsCsvToUpdate.map(paymentCsv -> { + paymentCsv.updateFrom(paymentDto); + write(paymentsCsv, false); + return PaymentCsvMapper.INSTANCE.csvToDomain(paymentCsv); + }); + } + + @Override + public boolean delete(UUID id) { + List paymentsCsv = findAllRaw(); + boolean removed = paymentsCsv.removeIf(paymentCsv -> paymentCsv.getId().equals(id)); + if (removed) { + write(paymentsCsv, false); + } + return removed; + } +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/mapper/PaymentCsvMapper.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/mapper/PaymentCsvMapper.java new file mode 100644 index 0000000..ce91792 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/mapper/PaymentCsvMapper.java @@ -0,0 +1,18 @@ +package pl.programistazacny.javadeveloperexercise.adapter.csv.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; +import pl.programistazacny.javadeveloperexercise.adapter.csv.model.PaymentCsv; +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto; +import pl.programistazacny.javadeveloperexercise.domain.model.Payment; + +@Mapper +public interface PaymentCsvMapper { + PaymentCsvMapper INSTANCE = Mappers.getMapper(PaymentCsvMapper.class); + + Payment csvToDomain(PaymentCsv paymentCsv); + + @Mapping(target = "id", ignore = true) + PaymentCsv dtoToCsv(PaymentDto paymentDto); +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/model/PaymentCsv.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/model/PaymentCsv.java new file mode 100644 index 0000000..b743c55 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/csv/model/PaymentCsv.java @@ -0,0 +1,34 @@ +package pl.programistazacny.javadeveloperexercise.adapter.csv.model; + +import com.opencsv.bean.CsvBindByPosition; +import lombok.Data; +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto; + +import java.math.BigDecimal; +import java.util.UUID; + +@Data +public class PaymentCsv { + + @CsvBindByPosition(position = 0) + private UUID id; + + @CsvBindByPosition(position = 1) + private BigDecimal amount; + + @CsvBindByPosition(position = 2) + private String currency; + + @CsvBindByPosition(position = 3) + private UUID userId; + + @CsvBindByPosition(position = 4) + private String targetAccountNumber; + + public void updateFrom(PaymentDto paymentDto) { + setAmount(paymentDto.getAmount()); + setCurrency(paymentDto.getCurrency()); + setUserId(paymentDto.getUserId()); + setTargetAccountNumber(paymentDto.getTargetAccountNumber()); + } +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2CrudRepository.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2CrudRepository.java new file mode 100644 index 0000000..129289d --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2CrudRepository.java @@ -0,0 +1,9 @@ +package pl.programistazacny.javadeveloperexercise.adapter.h2; + +import org.springframework.data.repository.CrudRepository; +import pl.programistazacny.javadeveloperexercise.adapter.h2.model.PaymentH2; + +import java.util.UUID; + +public interface PaymentH2CrudRepository extends CrudRepository { +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2Repository.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2Repository.java new file mode 100644 index 0000000..0772eae --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2Repository.java @@ -0,0 +1,67 @@ +package pl.programistazacny.javadeveloperexercise.adapter.h2; + +import lombok.RequiredArgsConstructor; +import pl.programistazacny.javadeveloperexercise.adapter.h2.mapper.PaymentH2Mapper; +import pl.programistazacny.javadeveloperexercise.adapter.h2.model.PaymentH2; +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto; +import pl.programistazacny.javadeveloperexercise.domain.model.Payment; +import pl.programistazacny.javadeveloperexercise.domain.port.PaymentRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +@RequiredArgsConstructor +public class PaymentH2Repository implements PaymentRepository { + + private final PaymentH2CrudRepository paymentH2CrudRepository; + + @Override + public Optional findById(UUID id) { + return paymentH2CrudRepository.findById(id).map(paymentH2 -> PaymentH2Mapper.INSTANCE.h2ToDomain(paymentH2)); + } + + @Override + public List findAll() { + return StreamSupport.stream(paymentH2CrudRepository.findAll().spliterator(), false) + .map(paymentH2 -> PaymentH2Mapper.INSTANCE.h2ToDomain(paymentH2)) + .collect(Collectors.toList()); + } + + @Override + public Payment create(PaymentDto paymentDto) { + PaymentH2 paymentH2 = PaymentH2.builder() + .amount(paymentDto.getAmount()) + .currency(paymentDto.getCurrency()) + .userId(paymentDto.getUserId()) + .targetAccountNumber(paymentDto.getTargetAccountNumber()) + .build(); + return PaymentH2Mapper.INSTANCE.h2ToDomain(paymentH2CrudRepository.save(paymentH2)); + } + + @Override + public Optional update(UUID id, PaymentDto paymentDto) { + if (paymentH2CrudRepository.existsById(id)) { + PaymentH2 paymentH2 = PaymentH2.builder() + .amount(paymentDto.getAmount()) + .currency(paymentDto.getCurrency()) + .userId(paymentDto.getUserId()) + .targetAccountNumber(paymentDto.getTargetAccountNumber()) + .build(); + paymentH2.setId(id); + return Optional.of(PaymentH2Mapper.INSTANCE.h2ToDomain(paymentH2CrudRepository.save(paymentH2))); + } + return Optional.empty(); + } + + @Override + public boolean delete(UUID id) { + if (paymentH2CrudRepository.existsById(id)) { + paymentH2CrudRepository.deleteById(id); + return true; + } + return false; + } +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/mapper/PaymentH2Mapper.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/mapper/PaymentH2Mapper.java new file mode 100644 index 0000000..f398d28 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/mapper/PaymentH2Mapper.java @@ -0,0 +1,18 @@ +package pl.programistazacny.javadeveloperexercise.adapter.h2.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; +import pl.programistazacny.javadeveloperexercise.adapter.h2.model.PaymentH2; +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto; +import pl.programistazacny.javadeveloperexercise.domain.model.Payment; + +@Mapper +public interface PaymentH2Mapper { + PaymentH2Mapper INSTANCE = Mappers.getMapper(PaymentH2Mapper.class); + + Payment h2ToDomain(PaymentH2 paymentCsv); + + @Mapping(target = "id", ignore = true) + PaymentH2 dtoToH2(PaymentDto paymentDto); +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/model/PaymentH2.java b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/model/PaymentH2.java new file mode 100644 index 0000000..db3d963 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/adapter/h2/model/PaymentH2.java @@ -0,0 +1,45 @@ +package pl.programistazacny.javadeveloperexercise.adapter.h2.model; + +import lombok.*; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.GenericGenerator; +import org.hibernate.annotations.Type; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import java.math.BigDecimal; +import java.util.UUID; + +@Entity +@Data +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PaymentH2 { + + @Id + @GeneratedValue(generator = "UUID") + @GenericGenerator( + name = "UUID", + strategy = "org.hibernate.id.UUIDGenerator" + ) + @Column(name = "ID", updatable = false, nullable = false) + @ColumnDefault("random_uuid()") + @Type(type = "uuid-char") + private UUID id; + + @Column(name = "AMOUNT", nullable = false) + private BigDecimal amount; + + @Column(name = "CURRENCY", nullable = false) + private String currency; + + @Column(name = "USER_ID", nullable = false) + @Type(type = "uuid-char") + private UUID userId; + + @Column(name = "TARGET_ACCOUNT_NUMBER", nullable = false) + private String targetAccountNumber; +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/application/Application.java b/src/main/java/pl/programistazacny/javadeveloperexercise/application/Application.java new file mode 100644 index 0000000..d09a017 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/application/Application.java @@ -0,0 +1,14 @@ +package pl.programistazacny.javadeveloperexercise.application; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; + +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/application/CsvConfiguration.java b/src/main/java/pl/programistazacny/javadeveloperexercise/application/CsvConfiguration.java new file mode 100644 index 0000000..f8bc276 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/application/CsvConfiguration.java @@ -0,0 +1,53 @@ +package pl.programistazacny.javadeveloperexercise.application; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import pl.programistazacny.javadeveloperexercise.adapter.csv.PaymentCsvRepository; + +import javax.validation.constraints.NotBlank; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Configuration +@ConditionalOnProperty(prefix = "storage", name = "mode", havingValue = "csv") +@Slf4j +class CsvConfiguration { + + @Bean + CsvProperties csvProperties() { + return new CsvProperties(); + } + + @Bean + PaymentCsvRepository csvPaymentRepository(CsvProperties csvProperties) { + createCsvIfNotExists(csvProperties); + return new PaymentCsvRepository(csvProperties.getFilename()); + } + + private void createCsvIfNotExists(CsvProperties csvProperties) { + Path pathToFile = Paths.get(csvProperties.getFilename()); + if (Files.notExists(pathToFile)) { + log.warn("Csv file doesn't exist. Creating..."); + try { + Path createdCsv = Files.createFile(pathToFile); + log.warn("New csv file was created: " + createdCsv); + } catch (IOException e) { + throw new RuntimeException("Problem with creating empty csv file!", e); + } + } + } + + @ConfigurationProperties(prefix = "csv") + @Data + static class CsvProperties { + + @NotBlank + private String filename = "payments.csv"; + } +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/application/H2Configuration.java b/src/main/java/pl/programistazacny/javadeveloperexercise/application/H2Configuration.java new file mode 100644 index 0000000..9cb4414 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/application/H2Configuration.java @@ -0,0 +1,27 @@ +package pl.programistazacny.javadeveloperexercise.application; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.PropertySource; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import pl.programistazacny.javadeveloperexercise.adapter.h2.PaymentH2CrudRepository; +import pl.programistazacny.javadeveloperexercise.adapter.h2.PaymentH2Repository; +import pl.programistazacny.javadeveloperexercise.adapter.h2.model.PaymentH2; + +@Configuration +@ConditionalOnProperty(prefix = "storage", name = "mode", havingValue = "h2") +@EnableJpaRepositories(basePackageClasses = PaymentH2CrudRepository.class) +@EntityScan(basePackageClasses = PaymentH2.class) +@PropertySource("classpath:h2.properties") +@Import(DataSourceAutoConfiguration.class) +public class H2Configuration { + + @Bean + PaymentH2Repository paymentH2Repository(PaymentH2CrudRepository paymentH2CrudRepository) { + return new PaymentH2Repository(paymentH2CrudRepository); + } +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/application/PaymentConfiguration.java b/src/main/java/pl/programistazacny/javadeveloperexercise/application/PaymentConfiguration.java new file mode 100644 index 0000000..5fbd333 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/application/PaymentConfiguration.java @@ -0,0 +1,21 @@ +package pl.programistazacny.javadeveloperexercise.application; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import pl.programistazacny.javadeveloperexercise.adapter.controller.PaymentController; +import pl.programistazacny.javadeveloperexercise.domain.port.PaymentRepository; +import pl.programistazacny.javadeveloperexercise.domain.PaymentService; + +@Configuration +class PaymentConfiguration { + + @Bean + PaymentService paymentService(PaymentRepository paymentRepository) { + return new PaymentService(paymentRepository); + } + + @Bean + PaymentController paymentController(PaymentService paymentService) { + return new PaymentController(paymentService); + } +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/domain/PaymentService.java b/src/main/java/pl/programistazacny/javadeveloperexercise/domain/PaymentService.java new file mode 100644 index 0000000..22025df --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/domain/PaymentService.java @@ -0,0 +1,36 @@ +package pl.programistazacny.javadeveloperexercise.domain; + +import lombok.RequiredArgsConstructor; +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto; +import pl.programistazacny.javadeveloperexercise.domain.model.Payment; +import pl.programistazacny.javadeveloperexercise.domain.port.PaymentRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + + public Optional get(UUID id) { + return paymentRepository.findById(id); + } + + public List getAll() { + return paymentRepository.findAll(); + } + + public Payment add(PaymentDto paymentDto) { + return paymentRepository.create(paymentDto); + } + + public Optional edit(UUID id, PaymentDto paymentDto) { + return paymentRepository.update(id, paymentDto); + } + + public boolean delete(UUID id) { + return paymentRepository.delete(id); + } +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/domain/dto/PaymentDto.java b/src/main/java/pl/programistazacny/javadeveloperexercise/domain/dto/PaymentDto.java new file mode 100644 index 0000000..319e05d --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/domain/dto/PaymentDto.java @@ -0,0 +1,14 @@ +package pl.programistazacny.javadeveloperexercise.domain.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.UUID; + +@Data +public class PaymentDto { + private BigDecimal amount; + private String currency; + private UUID userId; + private String targetAccountNumber; +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/domain/model/Payment.java b/src/main/java/pl/programistazacny/javadeveloperexercise/domain/model/Payment.java new file mode 100644 index 0000000..84fbdd5 --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/domain/model/Payment.java @@ -0,0 +1,15 @@ +package pl.programistazacny.javadeveloperexercise.domain.model; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.UUID; + +@Data +public class Payment { + private UUID id; + private BigDecimal amount; + private String currency; + private UUID userId; + private String targetAccountNumber; +} diff --git a/src/main/java/pl/programistazacny/javadeveloperexercise/domain/port/PaymentRepository.java b/src/main/java/pl/programistazacny/javadeveloperexercise/domain/port/PaymentRepository.java new file mode 100644 index 0000000..55c8a8b --- /dev/null +++ b/src/main/java/pl/programistazacny/javadeveloperexercise/domain/port/PaymentRepository.java @@ -0,0 +1,21 @@ +package pl.programistazacny.javadeveloperexercise.domain.port; + +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto; +import pl.programistazacny.javadeveloperexercise.domain.model.Payment; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface PaymentRepository { + + Optional findById(UUID id); + + List findAll(); + + Payment create(PaymentDto paymentDto); + + Optional update(UUID id, PaymentDto paymentDto); + + boolean delete(UUID id); +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..a1948d4 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,3 @@ +storage.mode=h2 + +logging.level.org.zalando.logbook=TRACE diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..fe37757 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,8 @@ + ___ ______ _ _____ _ + |_ | | _ \ | | | ___| (_) + | | __ _ __ __ __ _ | | | | ___ __ __ ___ | | ___ _ __ ___ _ __ | |__ __ __ ___ _ __ ___ _ ___ ___ + | | / _` |\ \ / / / _` | | | | | / _ \\ \ / / / _ \| | / _ \ | '_ \ / _ \| '__| | __| \ \/ / / _ \| '__| / __|| |/ __| / _ \ +/\__/ /| (_| | \ V / | (_| | | |/ / | __/ \ V / | __/| || (_) || |_) || __/| | | |___ > < | __/| | | (__ | |\__ \| __/ +\____/ \__,_| \_/ \__,_| |___/ \___| \_/ \___||_| \___/ | .__/ \___||_| \____/ /_/\_\ \___||_| \___||_||___/ \___| + | | + |_| \ No newline at end of file diff --git a/src/main/resources/h2.properties b/src/main/resources/h2.properties new file mode 100644 index 0000000..53bc2e1 --- /dev/null +++ b/src/main/resources/h2.properties @@ -0,0 +1,10 @@ +spring.datasource.url=jdbc:h2:mem:db +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console +spring.h2.console.settings.trace=false +spring.h2.console.settings.web-allow-others=false \ No newline at end of file diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/BaseSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/BaseSpecification.groovy new file mode 100644 index 0000000..e91ce6e --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/BaseSpecification.groovy @@ -0,0 +1,63 @@ +package pl.programistazacny.javadeveloperexercise + +import pl.programistazacny.javadeveloperexercise.adapter.controller.model.PaymentRequest +import pl.programistazacny.javadeveloperexercise.adapter.csv.model.PaymentCsv +import pl.programistazacny.javadeveloperexercise.adapter.h2.model.PaymentH2 +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto +import pl.programistazacny.javadeveloperexercise.domain.model.Payment +import spock.lang.Specification + +abstract class BaseSpecification extends Specification { + + protected PaymentRequest randomPaymentRequest() { + return new PaymentRequest( + new BigDecimal(BigInteger.valueOf(new Random().nextInt(10001)), 2), + "PLN", + UUID.randomUUID(), + "PL32109024025139464873117912" + ) + } + + protected PaymentRequest emptyPaymentRequest() { + return new PaymentRequest(null, null, null, null) + } + + protected Payment randomPayment() { + Payment payment = new Payment() + payment.setId(UUID.randomUUID()) + payment.setAmount(new BigDecimal(BigInteger.valueOf(new Random().nextInt(10001)), 2)) + payment.setCurrency("PLN") + payment.setUserId(UUID.randomUUID()) + payment.setTargetAccountNumber("PL32109024025139464873117912") + return payment + } + + protected PaymentCsv randomPaymentCsv() { + PaymentCsv paymentCsv = new PaymentCsv() + paymentCsv.setId(UUID.randomUUID()) + paymentCsv.setAmount(new BigDecimal(BigInteger.valueOf(new Random().nextInt(10001)), 2)) + paymentCsv.setCurrency("PLN") + paymentCsv.setUserId(UUID.randomUUID()) + paymentCsv.setTargetAccountNumber("PL32109024025139464873117912") + return paymentCsv + } + + protected PaymentDto randomPaymentDto() { + PaymentDto paymentDto = new PaymentDto() + paymentDto.setAmount(new BigDecimal(BigInteger.valueOf(new Random().nextInt(10001)), 2)) + paymentDto.setCurrency("PLN") + paymentDto.setUserId(UUID.randomUUID()) + paymentDto.setTargetAccountNumber("PL32109024025139464873117912") + return paymentDto + } + + protected PaymentH2 randomPaymentH2() { + return new PaymentH2( + UUID.randomUUID(), + new BigDecimal(BigInteger.valueOf(new Random().nextInt(10001)), 2), + "PLN", + UUID.randomUUID(), + "PL32109024025139464873117912" + ) + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/CsvPaymentIntegrationSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/CsvPaymentIntegrationSpecification.groovy new file mode 100644 index 0000000..7546c55 --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/CsvPaymentIntegrationSpecification.groovy @@ -0,0 +1,9 @@ +package pl.programistazacny.javadeveloperexercise + + +import org.springframework.test.context.TestPropertySource + +@TestPropertySource(properties = "storage.mode=csv") +class CsvPaymentIntegrationSpecification extends PaymentIntegrationSpecification { + +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/H2PaymentIntegrationSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/H2PaymentIntegrationSpecification.groovy new file mode 100644 index 0000000..73e08ac --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/H2PaymentIntegrationSpecification.groovy @@ -0,0 +1,9 @@ +package pl.programistazacny.javadeveloperexercise + + +import org.springframework.test.context.TestPropertySource + +@TestPropertySource(properties = "storage.mode=h2") +class H2PaymentIntegrationSpecification extends PaymentIntegrationSpecification { + +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/PaymentIntegrationSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/PaymentIntegrationSpecification.groovy new file mode 100644 index 0000000..b336e6c --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/PaymentIntegrationSpecification.groovy @@ -0,0 +1,182 @@ +package pl.programistazacny.javadeveloperexercise + +import org.springframework.http.MediaType +import pl.programistazacny.javadeveloperexercise.adapter.controller.model.PaymentRequest + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +abstract class PaymentIntegrationSpecification extends SpringBaseSpecification { + + def "should get 404 for nonexistent payment"() { + given: "random UUID" + def paymentId = UUID.randomUUID() + + expect: "404" + mvc.perform(get("/payment/$paymentId")).andExpect(status().isNotFound()) + } + + def "should save payment"() { + given: "payment with random data" + def paymentRequest = randomPaymentRequest() + + when: "save new payment" + def payment = toObject( + mvc.perform( + post("/payment") + .content(toJson(paymentRequest)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString() + ) + + then: "check result of saving new payment" + UUID.fromString(payment["id"]) + payment["amount"] == paymentRequest.getAmount() + payment["currency"] == paymentRequest.getCurrency() + payment["userId"] == paymentRequest.getUserId().toString() + payment["targetAccountNumber"] == paymentRequest.getTargetAccountNumber() + } + + def "should get 400 for saving payment without required data"() { + given: "payment without required data" + def paymentRequest = emptyPaymentRequest() + + expect: "400" + mvc.perform( + post("/payment") + .content(toJson(paymentRequest)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + } + + def "should save 3 payments ang get list of them"() { + given: "3 payments with random data" + def paymentRequest1 = randomPaymentRequest() + def paymentRequest2 = randomPaymentRequest() + def paymentRequest3 = randomPaymentRequest() + + when: "save new payments and get list of payments" + def payment1Id = toObject( + mvc.perform( + post("/payment") + .content(toJson(paymentRequest1)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString() + )["id"] + def payment2Id = toObject( + mvc.perform( + post("/payment") + .content(toJson(paymentRequest2)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString() + )["id"] + def payment3Id = toObject( + mvc.perform( + post("/payment") + .content(toJson(paymentRequest3)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString() + )["id"] + def paymentsIds = [payment1Id, payment2Id, payment3Id] + List payments = toObject( + mvc.perform( + get("/payments")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString() + ) + + then: "check list of payments" + payments.stream().filter(payment -> payment["id"] in paymentsIds).count() == paymentsIds.size() + } + + def "should save and delete payment"() { + given: "payment with random data" + def paymentRequest = randomPaymentRequest() + + when: "save new payment" + def paymentId = toObject( + mvc.perform( + post("/payment") + .content(toJson(paymentRequest)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString() + )["id"] + + then: "delete new payment" + mvc.perform(delete("/payment/$paymentId")).andExpect(status().isNoContent()) + } + + def "should get 404 for deleting nonexistent payment"() { + given: "random UUID" + def paymentId = UUID.randomUUID() + + expect: "404" + mvc.perform(delete("/payment/$paymentId")).andExpect(status().isNotFound()) + } + + def "should save and edit payment"() { + given: "payment with random data" + def paymentRequest = randomPaymentRequest() + def paymentEditRequest = new PaymentRequest( + paymentRequest.getAmount().add(new BigDecimal("200.00")), + paymentRequest.getCurrency(), + paymentRequest.getUserId(), + paymentRequest.getTargetAccountNumber() + ) + + when: "save new payment and edit it" + def payment = toObject( + mvc.perform( + post("/payment") + .content(toJson(paymentRequest)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString() + ) + def paymentId = payment["id"] + def paymentEdited = toObject( + mvc.perform( + put("/payment/$paymentId") + .content(toJson(paymentEditRequest)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString() + ) + + then: "check edited payment" + payment["id"] == paymentEdited["id"] + payment["amount"] != paymentEdited["amount"] + paymentEdited["amount"] == paymentEditRequest.getAmount() + payment["currency"] == paymentEdited["currency"] + payment["userId"] == paymentEdited["userId"] + payment["targetAccountNumber"] == paymentEdited["targetAccountNumber"] + } + + def "should get 404 for editing nonexistent payment"() { + given: "random UUID" + def paymentId = UUID.randomUUID() + def paymentEditRequest = randomPaymentRequest() + + expect: "404" + mvc.perform( + put("/payment/$paymentId") + .content(toJson(paymentEditRequest)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + } + +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/SpringBaseSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/SpringBaseSpecification.groovy new file mode 100644 index 0000000..789aa8c --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/SpringBaseSpecification.groovy @@ -0,0 +1,27 @@ +package pl.programistazacny.javadeveloperexercise + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.TestPropertySource +import org.springframework.test.web.servlet.MockMvc +import pl.programistazacny.javadeveloperexercise.application.Application + +@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource("classpath:test.properties") +@AutoConfigureMockMvc +abstract class SpringBaseSpecification extends BaseSpecification { + + @Autowired + protected MockMvc mvc + + protected String toJson(Object object) { + JsonOutput.toJson(object) + } + + protected Object toObject(String json) { + new JsonSlurper().parseText(json) + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/controller/mapper/PaymentControllerMapperSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/controller/mapper/PaymentControllerMapperSpecification.groovy new file mode 100644 index 0000000..b28b932 --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/controller/mapper/PaymentControllerMapperSpecification.groovy @@ -0,0 +1,42 @@ +package pl.programistazacny.javadeveloperexercise.adapter.controller.mapper + +import pl.programistazacny.javadeveloperexercise.BaseSpecification +import pl.programistazacny.javadeveloperexercise.adapter.controller.mapper.PaymentControllerMapper +import pl.programistazacny.javadeveloperexercise.adapter.controller.model.PaymentRequest +import pl.programistazacny.javadeveloperexercise.domain.model.Payment +import spock.lang.Shared + +class PaymentControllerMapperSpecification extends BaseSpecification { + + @Shared + PaymentControllerMapper mapper = PaymentControllerMapper.INSTANCE + + def "should map from request to dto"() { + given: + PaymentRequest request = randomPaymentRequest() + + when: + def dto = mapper.requestToDto(request) + + then: + dto.getAmount() == request.getAmount() + dto.getCurrency() == request.getCurrency() + dto.getUserId() == request.getUserId() + dto.getTargetAccountNumber() == request.getTargetAccountNumber() + } + + def "should map from domain to response"() { + given: + Payment domain = randomPayment() + + when: + def response = mapper.domainToResponse(domain) + + then: + response.getId() == domain.getId() + response.getAmount() == domain.getAmount() + response.getCurrency() == domain.getCurrency() + response.getUserId() == domain.getUserId() + response.getTargetAccountNumber() == domain.getTargetAccountNumber() + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/csv/PaymentCsvRepositorySpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/csv/PaymentCsvRepositorySpecification.groovy new file mode 100644 index 0000000..378e52b --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/csv/PaymentCsvRepositorySpecification.groovy @@ -0,0 +1,13 @@ +package pl.programistazacny.javadeveloperexercise.adapter.csv + +import org.springframework.test.context.TestPropertySource +import pl.programistazacny.javadeveloperexercise.domain.port.PaymentRepositorySpecification + +@TestPropertySource(properties = "storage.mode=csv") +class PaymentCsvRepositorySpecification extends PaymentRepositorySpecification { + + def "repository should be PaymentCsvRepository"() { + expect: + PaymentCsvRepository.class.isInstance(paymentRepository) + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/csv/mapper/PaymentCsvMapperSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/csv/mapper/PaymentCsvMapperSpecification.groovy new file mode 100644 index 0000000..8d41cba --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/csv/mapper/PaymentCsvMapperSpecification.groovy @@ -0,0 +1,43 @@ +package pl.programistazacny.javadeveloperexercise.adapter.csv.mapper + +import pl.programistazacny.javadeveloperexercise.BaseSpecification +import pl.programistazacny.javadeveloperexercise.adapter.csv.mapper.PaymentCsvMapper +import pl.programistazacny.javadeveloperexercise.adapter.csv.model.PaymentCsv +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto +import spock.lang.Shared + +class PaymentCsvMapperSpecification extends BaseSpecification { + + @Shared + PaymentCsvMapper mapper = PaymentCsvMapper.INSTANCE + + def "should map from csv to domain"() { + given: + PaymentCsv csv = randomPaymentCsv() + + when: + def domain = mapper.csvToDomain(csv) + + then: + domain.getId() == csv.getId() + domain.getAmount() == csv.getAmount() + domain.getCurrency() == csv.getCurrency() + domain.getUserId() == csv.getUserId() + domain.getTargetAccountNumber() == csv.getTargetAccountNumber() + } + + def "should map from dto to csv"() { + given: + PaymentDto dto = randomPaymentDto() + + when: + def csv = mapper.dtoToCsv(dto) + + then: + csv.getId() == null + csv.getAmount() == dto.getAmount() + csv.getCurrency() == dto.getCurrency() + csv.getUserId() == dto.getUserId() + csv.getTargetAccountNumber() == dto.getTargetAccountNumber() + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2RepositorySpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2RepositorySpecification.groovy new file mode 100644 index 0000000..d10d668 --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/h2/PaymentH2RepositorySpecification.groovy @@ -0,0 +1,13 @@ +package pl.programistazacny.javadeveloperexercise.adapter.h2 + +import org.springframework.test.context.TestPropertySource +import pl.programistazacny.javadeveloperexercise.domain.port.PaymentRepositorySpecification + +@TestPropertySource(properties = "storage.mode=h2") +class PaymentH2RepositorySpecification extends PaymentRepositorySpecification { + + def "repository should be PaymentH2Repository"() { + expect: + PaymentH2Repository.class.isInstance(paymentRepository) + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/h2/mapper/PaymentH2MapperSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/h2/mapper/PaymentH2MapperSpecification.groovy new file mode 100644 index 0000000..379a0c0 --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/adapter/h2/mapper/PaymentH2MapperSpecification.groovy @@ -0,0 +1,43 @@ +package pl.programistazacny.javadeveloperexercise.adapter.h2.mapper + +import pl.programistazacny.javadeveloperexercise.BaseSpecification +import pl.programistazacny.javadeveloperexercise.adapter.h2.mapper.PaymentH2Mapper +import pl.programistazacny.javadeveloperexercise.adapter.h2.model.PaymentH2 +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto +import spock.lang.Shared + +class PaymentH2MapperSpecification extends BaseSpecification { + + @Shared + PaymentH2Mapper mapper = PaymentH2Mapper.INSTANCE + + def "should map from h2 to domain"() { + given: + PaymentH2 h2 = randomPaymentH2() + + when: + def domain = mapper.h2ToDomain(h2) + + then: + domain.getId() == h2.getId() + domain.getAmount() == h2.getAmount() + domain.getCurrency() == h2.getCurrency() + domain.getUserId() == h2.getUserId() + domain.getTargetAccountNumber() == h2.getTargetAccountNumber() + } + + def "should map from dto to h2"() { + given: + PaymentDto dto = randomPaymentDto() + + when: + def h2 = mapper.dtoToH2(dto) + + then: + h2.getId() == null + h2.getAmount() == dto.getAmount() + h2.getCurrency() == dto.getCurrency() + h2.getUserId() == dto.getUserId() + h2.getTargetAccountNumber() == dto.getTargetAccountNumber() + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/application/ApplicationSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/application/ApplicationSpecification.groovy new file mode 100644 index 0000000..6ad56e5 --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/application/ApplicationSpecification.groovy @@ -0,0 +1,14 @@ +package pl.programistazacny.javadeveloperexercise.application + +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.TestPropertySource +import spock.lang.Specification + +@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource("classpath:test.properties") +class ApplicationSpecification extends Specification { + + def "should load context"() { + // that's all :) + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/application/ArchitectureSpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/application/ArchitectureSpecification.groovy new file mode 100644 index 0000000..7e3c67b --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/application/ArchitectureSpecification.groovy @@ -0,0 +1,55 @@ +package pl.programistazacny.javadeveloperexercise.application + + +import com.tngtech.archunit.core.domain.JavaClasses +import com.tngtech.archunit.core.importer.ClassFileImporter +import com.tngtech.archunit.core.importer.ImportOption +import com.tngtech.archunit.lang.ArchRule +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition +import com.tngtech.archunit.library.Architectures +import spock.lang.Shared +import spock.lang.Specification + +class ArchitectureSpecification extends Specification { + + @Shared + JavaClasses importedClasses = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("pl.programistazacny.javadeveloperexercise") + + def "domain classes should not be accessing classes from adapter or application"() { + given: + ArchRule rule = ArchRuleDefinition + .noClasses().that().resideInAnyPackage("..domain..") + .should().accessClassesThat().resideInAnyPackage("..adapter..", "..application..") + + expect: + rule.check(importedClasses) + } + + def "port should be an interface"() { + given: + ArchRule rule = ArchRuleDefinition + .classes().that().resideInAPackage("..domain.port") + .should().beInterfaces() + + expect: + rule.check(importedClasses) + } + + def "layers should have proper accesses"() { + given: + ArchRule rule = Architectures.layeredArchitecture() + + .layer("Domain").definedBy("..domain..") + .layer("Adapter").definedBy("..adapter..") + .layer("Application").definedBy("..application..") + + .whereLayer("Application").mayNotBeAccessedByAnyLayer() + .whereLayer("Adapter").mayOnlyBeAccessedByLayers("Application") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Adapter", "Application") + + expect: + rule.check(importedClasses) + } +} diff --git a/src/test/groovy/pl/programistazacny/javadeveloperexercise/domain/port/PaymentRepositorySpecification.groovy b/src/test/groovy/pl/programistazacny/javadeveloperexercise/domain/port/PaymentRepositorySpecification.groovy new file mode 100644 index 0000000..80ea96d --- /dev/null +++ b/src/test/groovy/pl/programistazacny/javadeveloperexercise/domain/port/PaymentRepositorySpecification.groovy @@ -0,0 +1,103 @@ +package pl.programistazacny.javadeveloperexercise.domain.port + +import org.springframework.beans.factory.annotation.Autowired +import pl.programistazacny.javadeveloperexercise.SpringBaseSpecification +import pl.programistazacny.javadeveloperexercise.domain.dto.PaymentDto + +abstract class PaymentRepositorySpecification extends SpringBaseSpecification { + + @Autowired + protected PaymentRepository paymentRepository; + + def "should create payment and find it by id"() { + given: + PaymentDto paymentDto = randomPaymentDto() + + when: + def paymentCreated = paymentRepository.create(paymentDto) + def paymentFound = paymentRepository.findById(paymentCreated.getId()) + + then: + paymentFound.isPresent() + paymentFound.get().getAmount() == paymentDto.getAmount() + paymentFound.get().getCurrency() == paymentDto.getCurrency() + paymentFound.get().getUserId() == paymentDto.getUserId() + paymentFound.get().getTargetAccountNumber() == paymentDto.getTargetAccountNumber() + } + + def "should not find payment by id"() { + given: + def paymentId = UUID.randomUUID() + + expect: + paymentRepository.findById(paymentId).isEmpty() + } + + def "should create 2 payments and get them"() { + given: + PaymentDto paymentDto1 = randomPaymentDto() + PaymentDto paymentDto2 = randomPaymentDto() + + when: + def payment1Id = paymentRepository.create(paymentDto1).getId() + def payment2Id = paymentRepository.create(paymentDto2).getId() + def paymentsIds = [payment1Id, payment2Id] + def payments = paymentRepository.findAll() + + then: + payments.stream().filter(payment -> payment.getId() in paymentsIds).count() == paymentsIds.size() + } + + def "should create and delete payment"() { + given: + PaymentDto paymentDto = randomPaymentDto() + + when: + def paymentCreatedId = paymentRepository.create(paymentDto).getId() + def deleted = paymentRepository.delete(paymentCreatedId) + + then: + deleted + } + + def "should not delete nonexistent payment"() { + given: + def paymentId = UUID.randomUUID() + + expect: + !paymentRepository.delete(paymentId) + } + + def "should create and update payment"() { + given: + PaymentDto paymentDto = randomPaymentDto() + PaymentDto paymentUpdateDto = new PaymentDto() + paymentUpdateDto.setAmount(paymentDto.getAmount()) + paymentUpdateDto.setCurrency("GBP") + paymentUpdateDto.setUserId(paymentDto.getUserId()) + paymentUpdateDto.setTargetAccountNumber(paymentDto.getTargetAccountNumber()) + + when: + def paymentCreated = paymentRepository.create(paymentDto) + def paymentUpdated = paymentRepository.update(paymentCreated.getId(), paymentUpdateDto) + + then: + paymentUpdated.isPresent() + paymentUpdated.get().getAmount() == paymentDto.getAmount() + paymentUpdated.get().getCurrency() == paymentUpdateDto.getCurrency() + paymentUpdated.get().getUserId() == paymentDto.getUserId() + paymentUpdated.get().getTargetAccountNumber() == paymentDto.getTargetAccountNumber() + } + + def "should not update nonexistent payment"() { + given: + def paymentId = UUID.randomUUID() + def paymentDto = randomPaymentDto() + + when: + def updated = paymentRepository.update(paymentId, paymentDto) + + then: + updated.isEmpty() + } +} diff --git a/src/test/java/pl/programistazacny/javadeveloperexercise/application/ContextLoadTest.java b/src/test/java/pl/programistazacny/javadeveloperexercise/application/ContextLoadTest.java new file mode 100644 index 0000000..bc020f3 --- /dev/null +++ b/src/test/java/pl/programistazacny/javadeveloperexercise/application/ContextLoadTest.java @@ -0,0 +1,15 @@ +package pl.programistazacny.javadeveloperexercise.application; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource("classpath:test.properties") +class ContextLoadTest { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/resources/test.properties b/src/test/resources/test.properties new file mode 100644 index 0000000..e69de29