feat!: initial prototype
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
.gradle
|
||||
build
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
#.idea/modules.xml
|
||||
#.idea/jarRepositories.xml
|
||||
#.idea/compiler.xml
|
||||
#.idea/libraries/
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
42
build.gradle.kts
Normal file
42
build.gradle.kts
Normal file
@@ -0,0 +1,42 @@
|
||||
plugins {
|
||||
java
|
||||
id("org.springframework.boot") version "3.5.4"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
id("com.ryandens.javaagent-test") version "0.9.1"
|
||||
id("io.freefair.lombok") version "8.14"
|
||||
}
|
||||
|
||||
group = "eu.bitfield"
|
||||
version = "0.1"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
implementation("org.springframework.boot:spring-boot-starter-webflux")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.postgresql:r2dbc-postgresql")
|
||||
implementation("io.projectreactor.addons:reactor-extra")
|
||||
runtimeOnly("org.bouncycastle:bcpkix-jdk18on:1.81")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("io.projectreactor:reactor-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testJavaagent("net.bytebuddy:byte-buddy-agent")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
db:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/db.Dockerfile
|
||||
container_name: db
|
||||
restart: always
|
||||
shm_size: 128mb
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: dev_pw
|
||||
POSTGRES_USER: recipes
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
db_data:
|
||||
3
docker/db.Dockerfile
Normal file
3
docker/db.Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM postgres:latest
|
||||
COPY src/main/resources/schema.sql /docker-entrypoint-initdb.d/
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
#Fri May 16 13:20:23 CEST 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
gradlew
vendored
Executable file
251
gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original 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.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# 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 ;; #(
|
||||
MSYS* | 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
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
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
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# 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"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
gradlew.bat
vendored
Normal file
94
gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@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
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@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=.
|
||||
@rem This is normally unused
|
||||
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% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
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% equ 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!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
1
settings.gradle.kts
Normal file
1
settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = "recipe-api"
|
||||
12
src/main/java/eu/bitfield/recipes/RecipesApplication.java
Normal file
12
src/main/java/eu/bitfield/recipes/RecipesApplication.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package eu.bitfield.recipes;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
|
||||
@SpringBootApplication
|
||||
public class RecipesApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RecipesApplication.class, args);
|
||||
}
|
||||
}
|
||||
113
src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java
Normal file
113
src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java
Normal file
@@ -0,0 +1,113 @@
|
||||
package eu.bitfield.recipes.api;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.validation.ObjectError;
|
||||
import org.springframework.validation.method.ParameterValidationResult;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.ErrorResponseException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.support.WebExchangeBindException;
|
||||
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
|
||||
import java.lang.reflect.Executable;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.CollectionUtils.*;
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
public interface ErrorResponseHandling {
|
||||
Logger log = LoggerFactory.getLogger(ErrorResponseHandling.class);
|
||||
|
||||
private static void addMethodParameter(ProblemDetail problemDetail, MethodParameter methodParam) {
|
||||
Executable executable = methodParam.getExecutable();
|
||||
Parameter param = methodParam.getParameter();
|
||||
String method = executable.toGenericString();
|
||||
String argument = param.getType().getCanonicalName() + " " + param.getName();
|
||||
problemDetail.setProperty("method", method);
|
||||
problemDetail.setProperty("argument", argument);
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
default ErrorResponse handleError(HandlerMethodValidationException e) {
|
||||
log.debug("""
|
||||
Handling {}:
|
||||
method: {}
|
||||
errors: {}""",
|
||||
e.getClass().getName(), e.getMethod(), e.getAllErrors(), e);
|
||||
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
|
||||
|
||||
String method = e.getMethod().toGenericString();
|
||||
problemDetail.setProperty("method", method);
|
||||
|
||||
MultiValueMap<String, String> errors = multiValueMap();
|
||||
for (ParameterValidationResult result : e.getParameterValidationResults()) {
|
||||
Parameter param = result.getMethodParameter().getParameter();
|
||||
String errorKey = param.getName();
|
||||
List<String> errorValues = result.getResolvableErrors()
|
||||
.stream()
|
||||
.map(MessageSourceResolvable::getDefaultMessage)
|
||||
.toList();
|
||||
errors.addAll(errorKey, errorValues);
|
||||
}
|
||||
problemDetail.setProperty("errors", errors);
|
||||
|
||||
return ErrorResponse.builder(e, problemDetail).build();
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
default ErrorResponse handleError(WebExchangeBindException e) {
|
||||
List<ObjectError> objectErrors = e.getAllErrors();
|
||||
log.debug("""
|
||||
Handling {}:
|
||||
methodParameter: {}
|
||||
errors: {}""",
|
||||
e.getClass().getName(), e.getMethodParameter(), e.getAllErrors(), e);
|
||||
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
|
||||
|
||||
MethodParameter methodParam = e.getMethodParameter();
|
||||
if (methodParam != null) addMethodParameter(problemDetail, methodParam);
|
||||
|
||||
MultiValueMap<String, String> errors = multiValueMap();
|
||||
for (ObjectError objectError : objectErrors) {
|
||||
String errorKey = objectError.getObjectName();
|
||||
if (objectError instanceof FieldError fieldError) {
|
||||
errorKey += "." + fieldError.getField();
|
||||
}
|
||||
errors.add(errorKey, objectError.getDefaultMessage());
|
||||
}
|
||||
problemDetail.setProperty("errors", errors);
|
||||
|
||||
return ErrorResponse.builder(e, problemDetail).build();
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
default ErrorResponse handleError(ServerWebInputException e) {
|
||||
log.debug("""
|
||||
Handling {}:
|
||||
methodParameter: {}""",
|
||||
e.getClass().getName(), e.getMethodParameter(), e);
|
||||
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
|
||||
|
||||
MethodParameter methodParam = e.getMethodParameter();
|
||||
if (methodParam != null) addMethodParameter(problemDetail, methodParam);
|
||||
|
||||
return ErrorResponse.builder(e, problemDetail).build();
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
default ErrorResponse handleError(ErrorResponseException e) {
|
||||
log.debug("Handling {}", e.getClass().getName(), e);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package eu.bitfield.recipes.api.account;
|
||||
|
||||
import eu.bitfield.recipes.api.ErrorResponseHandling;
|
||||
import eu.bitfield.recipes.core.account.AccountIn;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.ErrorUtils.*;
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController @RequestMapping("/api/account")
|
||||
public class AccountEndpoint implements ErrorResponseHandling {
|
||||
private final RegisterAccount registerOp;
|
||||
|
||||
@PostMapping("/register")
|
||||
public Mono<Void> registerAccount(@RequestBody @Valid AccountIn accountIn) {
|
||||
return registerOp.registerAccount(accountIn)
|
||||
.onErrorMap(RegisterAccount.EmailAlreadyInUse.class,
|
||||
e -> errorResponseException(e, BAD_REQUEST))
|
||||
.then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package eu.bitfield.recipes.api.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.password.Password;
|
||||
import eu.bitfield.recipes.core.account.Account;
|
||||
import eu.bitfield.recipes.core.account.AccountIn;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import eu.bitfield.recipes.core.profile.Profile;
|
||||
import eu.bitfield.recipes.core.profile.ProfileService;
|
||||
import eu.bitfield.recipes.view.registration.RegistrationView;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RegisterAccount {
|
||||
private final ProfileService profileServ;
|
||||
private final AccountService accountServ;
|
||||
|
||||
@Transactional
|
||||
public Mono<RegistrationView> registerAccount(AccountIn accountIn) {
|
||||
return Mono.defer(() -> {
|
||||
return accountServ.checkEmail(EmailAddress.of(accountIn.email()))
|
||||
.onErrorMap(AccountService.EmailAlreadyInUse.class, EmailAlreadyInUse::new);
|
||||
})
|
||||
.zipWhen((EmailAddress __) -> profileServ.addProfile())
|
||||
.flatMap(function((EmailAddress email, Profile profile) -> {
|
||||
return some(accountIn.password())
|
||||
.map(accountServ::encode)
|
||||
.flatMap((Password password) -> accountServ.addAccount(profile.id(), email, password))
|
||||
.map((Account account) -> new RegistrationView(profile, account));
|
||||
}));
|
||||
}
|
||||
|
||||
public static class EmailAlreadyInUse extends Error {
|
||||
public EmailAlreadyInUse(AccountService.EmailAlreadyInUse e) {super(e);}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(Throwable cause) {super("account registration failed", cause);}
|
||||
}
|
||||
}
|
||||
43
src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java
Normal file
43
src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.util.Chronology;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static reactor.core.publisher.Mono.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AddRecipe {
|
||||
private final RecipeService recipeServ;
|
||||
private final CategoryService categoryServ;
|
||||
private final LinkRecCatService linkServ;
|
||||
private final IngredientService ingredientServ;
|
||||
private final StepService stepServ;
|
||||
private final Chronology time;
|
||||
|
||||
|
||||
@Transactional
|
||||
public Mono<RecipeView> addRecipe(RecipeViewIn detailsIn, long profileId) {
|
||||
return defer(() -> recipeServ.addRecipe(profileId, detailsIn.recipe(), time.now()))
|
||||
.zipWith(categoryServ.addCategories(detailsIn.categories()))
|
||||
.flatMap(function((recipe, category) -> {
|
||||
return zip(linkServ.addLinks(recipe.id(), category),
|
||||
ingredientServ.addIngredients(recipe.id(), detailsIn.ingredients()),
|
||||
stepServ.addSteps(recipe.id(), detailsIn.steps()))
|
||||
.map(function((links, ingredients, steps) -> {
|
||||
return new RecipeView(recipe, category, links, ingredients, steps);
|
||||
}));
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FindRecipes {
|
||||
private final RecipeService recipeServ;
|
||||
private final GetRecipe getRecipe;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Mono<List<RecipeViewOut>> findRecipes(
|
||||
@Nullable String categoryName,
|
||||
@Nullable String recipeName,
|
||||
Pagination pagination)
|
||||
{
|
||||
return recipeServ.findRecipeIds(categoryName, recipeName, pagination)
|
||||
.flatMapSequential(getRecipe::getRecipe)
|
||||
.collectList(); // collect to close transaction on completion
|
||||
}
|
||||
}
|
||||
48
src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java
Normal file
48
src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.view.recipe.RecipeView.*;
|
||||
import static reactor.core.publisher.Mono.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GetRecipe {
|
||||
private final RecipeService recipeServ;
|
||||
private final CategoryService categoryServ;
|
||||
private final IngredientService ingredientServ;
|
||||
private final StepService stepServ;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Mono<RecipeViewOut> getRecipe(long recipeId) {
|
||||
return defer(() -> recipeServ.getRecipe(recipeId).onErrorMap(RecipeNotFound.class, NotFound::new))
|
||||
.flatMap(recipe -> {
|
||||
return zip(categoryServ.getCategories(recipeId),
|
||||
ingredientServ.getIngredients(recipeId),
|
||||
stepServ.getSteps(recipeId))
|
||||
.map(function((categories, ingredients, steps) -> {
|
||||
return createRecipeViewOut(recipe, categories, ingredients, steps);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
public static class NotFound extends Error {
|
||||
public NotFound(RecipeNotFound recipeNotFound) {super(recipeNotFound);}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(Throwable cause) {super("recipe details retrieval failed", cause);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.api.ErrorResponseHandling;
|
||||
import eu.bitfield.recipes.auth.ProfileIdentityAccess;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.ErrorUtils.*;
|
||||
import static java.util.function.Function.*;
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController @RequestMapping("/api/recipe")
|
||||
public class RecipeEndpoint implements ErrorResponseHandling {
|
||||
public static final long MIN_LIMIT = 1;
|
||||
public static final long MAX_LIMIT = 100;
|
||||
private final AddRecipe addOp;
|
||||
private final GetRecipe getOp;
|
||||
private final UpdateRecipe updateOp;
|
||||
private final RemoveRecipe removeOp;
|
||||
private final FindRecipes findOp;
|
||||
private final ProfileIdentityAccess profile;
|
||||
|
||||
@PostMapping("/new")
|
||||
public Mono<RecipeViewOut> addRecipe(@RequestBody @Valid RecipeViewIn recipeViewIn) {
|
||||
return profile.id()
|
||||
.flatMap(profileId -> addOp.addRecipe(recipeViewIn, profileId))
|
||||
.map(RecipeView::toRecipeViewOut);
|
||||
}
|
||||
|
||||
@GetMapping("/{recipeId}")
|
||||
public Mono<RecipeViewOut> getRecipe(@PathVariable long recipeId) {
|
||||
return getOp.getRecipe(recipeId)
|
||||
.onErrorMap(GetRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND));
|
||||
}
|
||||
|
||||
@PutMapping("/{recipeId}")
|
||||
public Mono<RecipeViewOut> updateRecipe(@PathVariable long recipeId,
|
||||
@RequestBody @Valid RecipeViewIn recipeViewIn)
|
||||
{
|
||||
return profile.id()
|
||||
.flatMap(profileId -> updateOp.updateRecipe(recipeId, recipeViewIn, profileId))
|
||||
.onErrorMap(UpdateRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND))
|
||||
.onErrorMap(UpdateRecipe.Forbidden.class, e -> errorResponseException(e, FORBIDDEN))
|
||||
.map(RecipeView::toRecipeViewOut);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{recipeId}")
|
||||
public Mono<Void> removeRecipe(@PathVariable long recipeId) {
|
||||
return profile.id()
|
||||
.flatMap(profileId -> removeOp.removeRecipe(recipeId, profileId))
|
||||
.onErrorMap(RemoveRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND))
|
||||
.onErrorMap(RemoveRecipe.Forbidden.class, e -> errorResponseException(e, FORBIDDEN))
|
||||
.then();
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public Flux<RecipeViewOut> findRecipes(
|
||||
@RequestParam(name = "category", required = false) @Nullable String categoryName,
|
||||
@RequestParam(name = "recipe", required = false) @Nullable String recipeName,
|
||||
@RequestParam(name = "limit") @Min(MIN_LIMIT) @Max(MAX_LIMIT) @Valid long limit,
|
||||
@RequestParam(name = "offset") @PositiveOrZero @Valid long offset)
|
||||
{
|
||||
return defer(() -> findOp.findRecipes(categoryName, recipeName, new Pagination(limit, offset)))
|
||||
.flatMapIterable(identity());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class RemoveRecipe {
|
||||
private final RecipeService recipeService;
|
||||
|
||||
@Transactional
|
||||
public Mono<Boolean> removeRecipe(long recipeId, long profileId) {
|
||||
return recipeService.removeRecipe(recipeId, profileId)
|
||||
.onErrorMap(RecipeNotFound.class, NotFound::new)
|
||||
.onErrorMap(RemoveRecipeForbidden.class, Forbidden::new);
|
||||
}
|
||||
|
||||
public static final class NotFound extends Error {
|
||||
public NotFound(RecipeNotFound recipeNotFound) {super(recipeNotFound);}
|
||||
}
|
||||
|
||||
public static class Forbidden extends Error {
|
||||
public Forbidden(RemoveRecipeForbidden forbidden) {super(forbidden);}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(Throwable cause) {super("recipe details removal failed", cause);}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.util.Chronology;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static reactor.core.publisher.Mono.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UpdateRecipe {
|
||||
private final RecipeService recipeServ;
|
||||
private final CategoryService categoryServ;
|
||||
private final LinkRecCatService linkServ;
|
||||
private final StepService stepServ;
|
||||
private final IngredientService ingredientServ;
|
||||
private final Chronology time;
|
||||
|
||||
@Transactional
|
||||
public Mono<RecipeView> updateRecipe(long recipeId, RecipeViewIn detailsIn, long profileId) {
|
||||
return Mono.defer(() -> {
|
||||
return recipeServ.updateRecipe(recipeId, profileId, detailsIn.recipe(), time.now())
|
||||
.onErrorMap(RecipeNotFound.class, NotFound::new)
|
||||
.onErrorMap(UpdateRecipeForbidden.class, Forbidden::new);
|
||||
})
|
||||
.zipWhen(__ -> categoryServ.addCategories(detailsIn.categories()))
|
||||
.flatMap(function((recipe, categories) -> {
|
||||
return zip(linkServ.updateLinks(recipeId, categories),
|
||||
stepServ.updateSteps(recipeId, detailsIn.steps()),
|
||||
ingredientServ.updateIngredients(recipeId, detailsIn.ingredients()))
|
||||
.map(function((links, steps, ingredients) -> {
|
||||
return new RecipeView(recipe, categories, links, ingredients, steps);
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
public static class NotFound extends Error {
|
||||
public NotFound(RecipeNotFound recipeNotFound) {
|
||||
super(recipeNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Forbidden extends Error {
|
||||
public Forbidden(UpdateRecipeForbidden forbidden) {
|
||||
super(forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(Throwable cause) {
|
||||
super("recipe details update failed", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java
Normal file
18
src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
import eu.bitfield.recipes.core.account.Account;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter @Accessors(fluent = true)
|
||||
public class AccountPrincipal extends User implements ProfileIdentity {
|
||||
private final long profileId;
|
||||
|
||||
public AccountPrincipal(Account account) {
|
||||
super(account.email(), account.passwordEncoded(), List.of());
|
||||
this.profileId = account.profileId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.email.EmailAddressIn;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @Transactional(readOnly = true)
|
||||
public class AccountPrincipalService implements ReactiveUserDetailsService {
|
||||
private final AccountService accountServ;
|
||||
|
||||
@Override
|
||||
public Mono<UserDetails> findByUsername(String address) {
|
||||
return some(address)
|
||||
.mapNotNull(EmailAddressIn::of)
|
||||
.map(EmailAddress::of)
|
||||
.flatMap(accountServ::getAccount)
|
||||
.map(AccountPrincipal::new);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
public interface ProfileIdentity {
|
||||
long profileId();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
public class ProfileIdentityAccess {
|
||||
public Mono<Long> id() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getPrincipal)
|
||||
.cast(ProfileIdentity.class)
|
||||
.map(ProfileIdentity::profileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.bitfield.recipes.auth.email;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
import static java.util.Comparator.*;
|
||||
|
||||
@Getter @Accessors(fluent = true) @EqualsAndHashCode
|
||||
public class EmailAddress implements Comparable<EmailAddress> {
|
||||
public static Comparator<EmailAddress> order = comparing(EmailAddress::address);
|
||||
private final String address;
|
||||
|
||||
private EmailAddress(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public static EmailAddress of(EmailAddressIn emailIn) {
|
||||
return new EmailAddress(emailIn.address().toLowerCase());
|
||||
}
|
||||
|
||||
public int compareTo(@Nullable EmailAddress other) {
|
||||
return order.compare(this, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package eu.bitfield.recipes.auth.email;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static java.util.Comparator.*;
|
||||
|
||||
@Getter @Accessors(fluent = true) @EqualsAndHashCode
|
||||
public class EmailAddressIn implements Comparable<EmailAddressIn> {
|
||||
private static final String EMAIL_REGEX =
|
||||
"^[\\p{Alnum}!#$%&'*+/=?^_`{|}~-]+(?:\\.[\\p{Alnum}!#$%&'*+/=?^_`{|}~-]+)*@" +
|
||||
"\\p{Alnum}(?:[\\p{Alnum}-]{0,61}\\p{Alnum})?(?:\\.\\p{Alnum}(?:[\\p{Alnum}-]{0,61}\\p{Alnum})?)+$";
|
||||
private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);
|
||||
// based on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation/test
|
||||
public static Comparator<EmailAddressIn> order = comparing(EmailAddressIn::address);
|
||||
private final @JsonValue @NotNull @Email(regexp = EMAIL_REGEX) String address;
|
||||
|
||||
@JsonCreator
|
||||
private EmailAddressIn(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public static @Nullable EmailAddressIn of(String address) {
|
||||
if (address == null || !EMAIL_PATTERN.matcher(address).matches()) return null;
|
||||
return new EmailAddressIn(address);
|
||||
}
|
||||
|
||||
public static EmailAddressIn ofUnchecked(String address) {
|
||||
return new EmailAddressIn(address);
|
||||
}
|
||||
|
||||
public int compareTo(@Nullable EmailAddressIn other) {
|
||||
return order.compare(this, other);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.bitfield.recipes.auth.password;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
import static java.util.Comparator.*;
|
||||
|
||||
@Getter @Accessors(fluent = true) @EqualsAndHashCode
|
||||
public class Password implements Comparable<Password> {
|
||||
public static Comparator<Password> order = comparing(Password::encoded);
|
||||
private final String encoded;
|
||||
|
||||
private Password(String encoded) {
|
||||
this.encoded = encoded;
|
||||
}
|
||||
|
||||
public static Password of(Encoder encoder, PasswordIn passwordIn) {
|
||||
return new Password(encoder.encode(passwordIn));
|
||||
}
|
||||
|
||||
public static Password ofUnchecked(String encoded) {
|
||||
return new Password(encoded);
|
||||
}
|
||||
|
||||
public int compareTo(@Nullable Password other) {
|
||||
return order.compare(this, other);
|
||||
}
|
||||
|
||||
public interface Encoder {
|
||||
String encode(PasswordIn password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package eu.bitfield.recipes.auth.password;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
import static java.util.Comparator.*;
|
||||
|
||||
@Getter @Accessors(fluent = true) @EqualsAndHashCode
|
||||
public class PasswordIn implements Comparable<PasswordIn> {
|
||||
public static final int MIN_PASSWORD_LENGTH = 8;
|
||||
public static Comparator<PasswordIn> order = comparing(PasswordIn::raw);
|
||||
private final @JsonValue @NotNull @Size(min = MIN_PASSWORD_LENGTH) String raw;
|
||||
|
||||
@JsonCreator
|
||||
private PasswordIn(String raw) {
|
||||
this.raw = raw;
|
||||
}
|
||||
|
||||
public static PasswordIn ofUnchecked(String raw) {
|
||||
return new PasswordIn(raw);
|
||||
}
|
||||
|
||||
public int compareTo(@Nullable PasswordIn other) {
|
||||
return order.compare(this, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.bitfield.recipes.config;
|
||||
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.concurrent.ConcurrentMapCache;
|
||||
import org.springframework.cache.support.SimpleCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
public class CacheConfiguration {
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
SimpleCacheManager cacheManager = new SimpleCacheManager();
|
||||
cacheManager.setCaches(caches());
|
||||
return cacheManager;
|
||||
}
|
||||
|
||||
List<Cache> caches() {
|
||||
return Stream.of("account")
|
||||
.map(ConcurrentMapCache::new)
|
||||
.map(Cache.class::cast)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eu.bitfield.recipes.config;
|
||||
|
||||
import io.r2dbc.spi.Option;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.r2dbc.ConnectionFactoryOptionsBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class DatabaseConfiguration {
|
||||
@Bean
|
||||
public ConnectionFactoryOptionsBuilderCustomizer connectionFactoryOptionsBuilderCustomizer() {
|
||||
return builder -> {
|
||||
// builder.option(Option.valueOf("timeZone"), TimeZone.getTimeZone(ZoneOffset.UTC));
|
||||
builder.option(Option.valueOf("autodetectExtensions"), false);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package eu.bitfield.recipes.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
|
||||
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
|
||||
|
||||
import static org.springframework.http.HttpMethod.*;
|
||||
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
public class SecurityConfiguration {
|
||||
private SecurityWebFilterChain filterChainDefaults(ServerHttpSecurity http) {
|
||||
// disable session management
|
||||
// https://github.com/spring-projects/spring-security/issues/6552#issuecomment-519398510
|
||||
return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE) @Bean
|
||||
public SecurityWebFilterChain accountEndpointFilterChain(ServerHttpSecurity httpSecurity) {
|
||||
httpSecurity.securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/account/**"))
|
||||
.authorizeExchange(exchange -> exchange.anyExchange().permitAll());
|
||||
return filterChainDefaults(httpSecurity);
|
||||
}
|
||||
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE) @Bean
|
||||
public SecurityWebFilterChain recipeEndpointFilterChain(ServerHttpSecurity httpSecurity) {
|
||||
httpSecurity.securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/recipe/**"))
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.authorizeExchange(exchange -> {
|
||||
exchange.pathMatchers(GET, "/api/recipe/{recipeId:\\d+}").permitAll()
|
||||
.pathMatchers(GET, "/api/recipe/search").permitAll()
|
||||
.anyExchange().authenticated();
|
||||
});
|
||||
return filterChainDefaults(httpSecurity);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityWebFilterChain fallbackFilterChain(ServerHttpSecurity httpSecurity) {
|
||||
httpSecurity.authorizeExchange(exchange -> exchange.anyExchange().denyAll());
|
||||
return filterChainDefaults(httpSecurity);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
||||
// accessed at 2025-04-22
|
||||
int saltLength = 16;
|
||||
int hashLength = 32;
|
||||
int parallelism = 1;
|
||||
int memory = 1 << 16; // in KiB = 64 MiB
|
||||
int iterations = 2;
|
||||
return new Argon2PasswordEncoder(saltLength, hashLength, parallelism, memory, iterations);
|
||||
}
|
||||
}
|
||||
21
src/main/java/eu/bitfield/recipes/core/account/Account.java
Normal file
21
src/main/java/eu/bitfield/recipes/core/account/Account.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.password.Password;
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
|
||||
@With @FieldNameConstants
|
||||
public record Account(
|
||||
@Id long id,
|
||||
long profileId,
|
||||
String email,
|
||||
@Column("password") String passwordEncoded
|
||||
) implements Entity {
|
||||
public static Account initial(long profileId, EmailAddress email, Password password) {
|
||||
return new Account(0, profileId, email.address(), password.encoded());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddressIn;
|
||||
import eu.bitfield.recipes.auth.password.PasswordIn;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.With;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
@With
|
||||
@Validated
|
||||
public record AccountIn(
|
||||
@NotNull @Valid EmailAddressIn email,
|
||||
@NotNull @Valid PasswordIn password
|
||||
) implements ToAccountIn {
|
||||
public AccountIn toAccountIn() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
interface AccountRepository extends ReactiveCrudRepository<Account, Long> {
|
||||
default Mono<Account> addAccount(Account account) {
|
||||
return save(account);
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select * from account
|
||||
where email = $1
|
||||
""")
|
||||
Mono<Account> query_accountByEmail(String emailAddress);
|
||||
|
||||
default Mono<Account> accountByEmail(EmailAddress email) {
|
||||
return query_accountByEmail(email.address());
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select exists(
|
||||
select * from account
|
||||
where email = $1
|
||||
)
|
||||
""")
|
||||
Mono<Boolean> query_isEmailUsed(String emailAddress);
|
||||
|
||||
|
||||
default Mono<Boolean> isEmailUsed(EmailAddress email) {
|
||||
return query_isEmailUsed(email.address());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.password.Password;
|
||||
import eu.bitfield.recipes.auth.password.PasswordIn;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheConfig;
|
||||
import org.springframework.cache.annotation.CachePut;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @CacheConfig(cacheNames = "account")
|
||||
public class AccountService {
|
||||
private final AccountRepository repo;
|
||||
private final PasswordEncoder encoder;
|
||||
|
||||
@Transactional @CachePut(key = "email")
|
||||
public Mono<Account> addAccount(long profileId, EmailAddress email, Password password) {
|
||||
return supply(() -> Account.initial(profileId, email, password)).flatMap(repo::addAccount);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true) @Cacheable
|
||||
public Mono<Account> getAccount(EmailAddress email) {
|
||||
return repo.accountByEmail(email);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Mono<EmailAddress> checkEmail(EmailAddress email) {
|
||||
return repo.isEmailUsed(email).as(errIfTrue(() -> new EmailAlreadyInUse(email))).thenReturn(email);
|
||||
}
|
||||
|
||||
public Password encode(PasswordIn passwordIn) {
|
||||
return Password.of((PasswordIn __) -> encoder.encode(passwordIn.raw()), passwordIn);
|
||||
}
|
||||
|
||||
public static class EmailAlreadyInUse extends Error {
|
||||
public EmailAlreadyInUse(EmailAddress email) {
|
||||
super("address address '" + email.address() + "' is already in use");
|
||||
}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(String error) {super(error);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
public interface ToAccountIn {
|
||||
AccountIn toAccountIn();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Category(
|
||||
@Id long id,
|
||||
String name
|
||||
) implements Entity, ToCategoryOut {
|
||||
public static Category initial(String name) {
|
||||
return new Category(0, name);
|
||||
}
|
||||
|
||||
public CategoryOut toCategoryOut() {
|
||||
return new CategoryOut(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CategoryIn(@JsonValue @NotBlank String name) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public record CategoryOut(@JsonValue String name) implements ToCategoryOut {
|
||||
public CategoryOut toCategoryOut() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface CategoryRepository extends ReactiveCrudRepository<Category, Long> {
|
||||
default Mono<Category> addCategory(Category category) {
|
||||
return save(category);
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select * from category as c
|
||||
inner join link_rec_cat as rc on c.id = rc.category_id
|
||||
where rc.recipe_id = $1
|
||||
""")
|
||||
Flux<Category> categoriesByRecipeId(long recipeId);
|
||||
|
||||
@Query("""
|
||||
select id from category
|
||||
where name = $1
|
||||
""")
|
||||
Mono<Long> categoryIdByName(String name);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @Transactional(readOnly = true)
|
||||
public class CategoryService {
|
||||
private final CategoryRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<List<Category>> addCategories(List<CategoryIn> categoriesIn) {
|
||||
return flux(categoriesIn).flatMapSequential(this::addCategoryIfAbsent).collectList();
|
||||
}
|
||||
|
||||
public Mono<List<Category>> getCategories(long recipeId) {
|
||||
return repo.categoriesByRecipeId(recipeId).collectList();
|
||||
}
|
||||
|
||||
private Mono<Category> addCategoryIfAbsent(CategoryIn categoryIn) {
|
||||
return repo.categoryIdByName(categoryIn.name())
|
||||
.map(id -> new Category(id, categoryIn.name()))
|
||||
.switchIfEmpty(supply(() -> Category.initial(categoryIn.name())).flatMap(repo::addCategory));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
public interface ToCategoryIn {
|
||||
CategoryIn toCategoryIn();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
public interface ToCategoryOut {
|
||||
CategoryOut toCategoryOut();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Ingredient(
|
||||
@Id long id,
|
||||
long recipeId,
|
||||
String name
|
||||
) implements Entity, ToIngredientOut {
|
||||
public static Ingredient initial(long recipeId, String name) {return new Ingredient(0, recipeId, name);}
|
||||
|
||||
public IngredientOut toIngredientOut() {
|
||||
return new IngredientOut(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record IngredientIn(@JsonValue @NotBlank String name) {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public record IngredientOut(@JsonValue String name) {}
|
||||
@@ -0,0 +1,93 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import io.r2dbc.spi.Result;
|
||||
import io.r2dbc.spi.Row;
|
||||
import io.r2dbc.spi.RowMetadata;
|
||||
import io.r2dbc.spi.Statement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.r2dbc.connection.R2dbcTransactionManager;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Spliterator;
|
||||
|
||||
interface IngredientBatchOperations {
|
||||
Flux<Ingredient> addIngredientsBatch(Flux<Ingredient> ingredients);
|
||||
}
|
||||
|
||||
public interface IngredientRepository extends ReactiveCrudRepository<Ingredient, Long>, IngredientBatchOperations {
|
||||
// no batch support as of 2025-05-05
|
||||
// https://github.com/spring-projects/spring-data-r2dbc/issues/259
|
||||
// https://github.com/spring-projects/spring-framework/issues/33812
|
||||
default Flux<Ingredient> addIngredients(List<Ingredient> ingredients) {
|
||||
return saveAll(ingredients);
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select * from ingredient
|
||||
where recipe_id = $1
|
||||
""")
|
||||
Flux<Ingredient> ingredientsByRecipeId(long recipeId);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from ingredient where recipe_id = $1
|
||||
""")
|
||||
Mono<Long> removeIngredientsFromRecipe(long recipeId);
|
||||
}
|
||||
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
class IngredientBatchOperationsImpl implements IngredientBatchOperations {
|
||||
private final DatabaseClient client;
|
||||
private final R2dbcTransactionManager transactionManager;
|
||||
private final String addBatchSql =
|
||||
"""
|
||||
insert into ingredient(recipe_id, name) values ($1, $2)
|
||||
""";
|
||||
private final String idColumn = Ingredient.Fields.id;
|
||||
|
||||
@Override
|
||||
public Flux<Ingredient> addIngredientsBatch(Flux<Ingredient> ingredients) {
|
||||
return ingredients.collectList()
|
||||
.flatMapMany(this::addIngredientsBatch);
|
||||
}
|
||||
|
||||
public Flux<Ingredient> addIngredientsBatch(List<Ingredient> ingredients) {
|
||||
if (ingredients.isEmpty()) {
|
||||
return Flux.empty();
|
||||
}
|
||||
return client.inConnectionMany(connection -> {
|
||||
Statement statement = connection.createStatement(addBatchSql);
|
||||
statement.returnGeneratedValues(idColumn);
|
||||
Spliterator<Ingredient> iter = ingredients.spliterator();
|
||||
iter.tryAdvance(ingredient -> bind(statement, ingredient));
|
||||
iter.forEachRemaining(ingredient -> bind(statement.add(), ingredient));
|
||||
var results = Flux.from(statement.execute());
|
||||
return Flux.fromIterable(ingredients)
|
||||
.zipWith(results.concatMap(this::idFromResult), Ingredient::withId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Publisher<Long> idFromResult(Result result) {
|
||||
return result.map((Row row, RowMetadata rowMetadata) -> idFromRow(row));
|
||||
}
|
||||
|
||||
Long idFromRow(Row row) {
|
||||
Long id = row.get(idColumn, Long.class);
|
||||
return id;
|
||||
}
|
||||
|
||||
void bind(Statement statement, Ingredient ingredient) {
|
||||
statement.bind(0, ingredient.recipeId())
|
||||
.bind(1, ingredient.name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class IngredientService {
|
||||
private final IngredientRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<List<Ingredient>> addIngredients(long recipeId, List<IngredientIn> ingredientsIn) {
|
||||
return flux(ingredientsIn)
|
||||
.map(ingredientIn -> Ingredient.initial(recipeId, ingredientIn.name()))
|
||||
.collectList()
|
||||
.flatMapMany(repo::addIngredients)
|
||||
.collectList();
|
||||
}
|
||||
|
||||
public Mono<List<Ingredient>> getIngredients(long recipeId) {
|
||||
return repo.ingredientsByRecipeId(recipeId).collectList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<List<Ingredient>> updateIngredients(long recipeId, List<IngredientIn> ingredients) {
|
||||
return repo.removeIngredientsFromRecipe(recipeId).then(addIngredients(recipeId, ingredients));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
|
||||
public interface ToIngredientIn {
|
||||
IngredientIn toIngredientIn();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
public interface ToIngredientOut {
|
||||
IngredientOut toIngredientOut();
|
||||
}
|
||||
|
||||
20
src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java
Normal file
20
src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package eu.bitfield.recipes.core.link;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
/// recipe category link
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record LinkRecCat(
|
||||
@Id long id,
|
||||
long recipeId,
|
||||
long categoryId
|
||||
) implements Entity {
|
||||
public static LinkRecCat initial(long recipeId, long categoryId) {
|
||||
return new LinkRecCat(0, recipeId, categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package eu.bitfield.recipes.core.link;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface LinkRecCatRepository extends ReactiveCrudRepository<LinkRecCat, Long> {
|
||||
default Flux<LinkRecCat> addLinks(List<LinkRecCat> links) {
|
||||
return saveAll(links);
|
||||
}
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from link_rec_cat where recipe_id = $1
|
||||
""")
|
||||
Mono<Long> removeCategoriesFromRecipe(long recipeId);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package eu.bitfield.recipes.core.link;
|
||||
|
||||
import eu.bitfield.recipes.core.category.Category;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class LinkRecCatService {
|
||||
private final LinkRecCatRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<List<LinkRecCat>> addLinks(long recipeId, List<Category> categories) {
|
||||
return flux(categories)
|
||||
.map(category -> LinkRecCat.initial(recipeId, category.id()))
|
||||
.collectList()
|
||||
.flatMapMany(repo::addLinks)
|
||||
.collectList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<List<LinkRecCat>> updateLinks(long recipeId, List<Category> categories) {
|
||||
return repo.removeCategoriesFromRecipe(recipeId).then(addLinks(recipeId, categories));
|
||||
}
|
||||
}
|
||||
14
src/main/java/eu/bitfield/recipes/core/profile/Profile.java
Normal file
14
src/main/java/eu/bitfield/recipes/core/profile/Profile.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package eu.bitfield.recipes.core.profile;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Profile(@Id long id) implements Entity {
|
||||
public static Profile initial() {return new Profile(0);}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package eu.bitfield.recipes.core.profile;
|
||||
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface ProfileRepository extends ReactiveCrudRepository<Profile, Long> {
|
||||
default Mono<Profile> addProfile(Profile initialProfile) {
|
||||
return save(initialProfile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.bitfield.recipes.core.profile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @Transactional(readOnly = true)
|
||||
public class ProfileService {
|
||||
private final ProfileRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<Profile> addProfile() {return supply(Profile::initial).flatMap(repo::addProfile);}
|
||||
}
|
||||
27
src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java
Normal file
27
src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Recipe(
|
||||
@Id long id,
|
||||
long authorProfileId,
|
||||
String name,
|
||||
String description,
|
||||
Instant changedAt
|
||||
) implements Entity, ToRecipeOut {
|
||||
public static Recipe initial(long authorProfileId, String name, String description, Instant changedAt) {
|
||||
return new Recipe(0, authorProfileId, name, description, changedAt);
|
||||
}
|
||||
|
||||
public RecipeOut toRecipeOut() {
|
||||
return new RecipeOut(id, name, description, changedAt);
|
||||
}
|
||||
}
|
||||
|
||||
11
src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java
Normal file
11
src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.With;
|
||||
|
||||
@With
|
||||
public record RecipeIn(
|
||||
@NotBlank String name,
|
||||
@NotBlank String description
|
||||
) {
|
||||
}
|
||||
13
src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java
Normal file
13
src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import lombok.With;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@With
|
||||
public record RecipeOut(
|
||||
long id,
|
||||
String name,
|
||||
String description,
|
||||
Instant changedAt
|
||||
) {}
|
||||
@@ -0,0 +1,61 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.lang.Nullable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public interface RecipeRepository extends ReactiveCrudRepository<Recipe, Long> {
|
||||
default Mono<Recipe> addRecipe(Recipe recipe) {
|
||||
return save(recipe);
|
||||
}
|
||||
|
||||
default Mono<Recipe> recipe(long recipeId) {
|
||||
return findById(recipeId);
|
||||
}
|
||||
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
update recipe
|
||||
set name = $2,
|
||||
description = $3,
|
||||
changed_at = $4
|
||||
where id = $1
|
||||
""")
|
||||
Mono<Boolean> updateRecipe(long recipeId, String name, String description, Instant changedAt);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from recipe
|
||||
where id = $1
|
||||
""")
|
||||
Mono<Boolean> removeRecipe(long recipeId);
|
||||
|
||||
@Query("""
|
||||
select id from recipe as r
|
||||
where
|
||||
($2 is null or position(lower($2) in lower(r.name)) > 0) and
|
||||
($1 is null or
|
||||
exists(
|
||||
select * from category as c
|
||||
inner join link_rec_cat as rc on c.id = rc.category_id
|
||||
where lower(c.name) = lower($1) and rc.recipe_id = r.id
|
||||
)
|
||||
)
|
||||
order by r.changed_at desc
|
||||
limit $3
|
||||
offset $4
|
||||
""")
|
||||
Flux<Long> recipeIds(@Nullable String categoryName, @Nullable String recipeName, long limit, long offset);
|
||||
|
||||
@Query("""
|
||||
select author_profile_id = $2 from recipe
|
||||
where id = $1
|
||||
""")
|
||||
Mono<Boolean> canEditRecipe(long recipeId, long profileId);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @Transactional(readOnly = true)
|
||||
public class RecipeService {
|
||||
private final RecipeRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<Recipe> addRecipe(long authorProfileId, RecipeIn recipeIn, Instant createdAt) {
|
||||
return repo.addRecipe(Recipe.initial(authorProfileId, recipeIn.name(), recipeIn.description(), createdAt));
|
||||
}
|
||||
|
||||
public Mono<Recipe> getRecipe(long recipeId) {
|
||||
return repo.recipe(recipeId).as(checkEmpty(recipeId));
|
||||
}
|
||||
|
||||
public Flux<Long> findRecipeIds(@Nullable String categoryName, @Nullable String recipeName, Pagination pagination) {
|
||||
return repo.recipeIds(categoryName, recipeName, pagination.limit(), pagination.offset());
|
||||
}
|
||||
|
||||
public Mono<Recipe> updateRecipe(long recipeId, long profileId, RecipeIn recipeIn, Instant changedAt) {
|
||||
return checkEdit(recipeId, profileId, () -> new UpdateRecipeForbidden(recipeId))
|
||||
.then(repo.updateRecipe(recipeId, recipeIn.name(), recipeIn.description(), changedAt))
|
||||
.then(supply(() -> new Recipe(recipeId, profileId, recipeIn.name(), recipeIn.description(), changedAt)));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Boolean> removeRecipe(long recipeId, long profileId) {
|
||||
return checkEdit(recipeId, profileId, () -> new RemoveRecipeForbidden(recipeId))
|
||||
.then(repo.removeRecipe(recipeId));
|
||||
}
|
||||
|
||||
private <T> UnaryOperator<Mono<T>> checkEmpty(long recipeId) {
|
||||
return (Mono<T> item) -> item.as(errIfEmpty(() -> new RecipeNotFound(recipeId)));
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkEdit(long recipeId, long profileId, Supplier<? extends Throwable> forbiddenError) {
|
||||
return repo.canEditRecipe(recipeId, profileId)
|
||||
.as(checkEmpty(recipeId))
|
||||
.as(errIfFalse(forbiddenError));
|
||||
}
|
||||
|
||||
public static class RecipeNotFound extends Error {
|
||||
public RecipeNotFound(long recipeId) {super("no such recipe (id=" + recipeId + ")");}
|
||||
}
|
||||
|
||||
public static class UpdateRecipeForbidden extends Error {
|
||||
public UpdateRecipeForbidden(long recipeId) {super("updating recipe (id=" + recipeId + ") is not allowed");}
|
||||
}
|
||||
|
||||
public static class RemoveRecipeForbidden extends Error {
|
||||
public RemoveRecipeForbidden(long recipeId) {super("removing recipe (id=" + recipeId + ") is not allowed");}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(String error) {super(error);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
public interface ToRecipeIn {
|
||||
RecipeIn toRecipeIn();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
public interface ToRecipeOut {
|
||||
RecipeOut toRecipeOut();
|
||||
}
|
||||
21
src/main/java/eu/bitfield/recipes/core/step/Step.java
Normal file
21
src/main/java/eu/bitfield/recipes/core/step/Step.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Step(
|
||||
@Id long id,
|
||||
long recipeId,
|
||||
String name
|
||||
) implements Entity, ToStepOut {
|
||||
public static Step initial(long recipeId, String name) {return new Step(0, recipeId, name);}
|
||||
|
||||
public StepOut toStepOut() {
|
||||
return new StepOut(name);
|
||||
}
|
||||
}
|
||||
|
||||
7
src/main/java/eu/bitfield/recipes/core/step/StepIn.java
Normal file
7
src/main/java/eu/bitfield/recipes/core/step/StepIn.java
Normal file
@@ -0,0 +1,7 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record StepIn(@JsonValue @NotBlank String name) {
|
||||
}
|
||||
5
src/main/java/eu/bitfield/recipes/core/step/StepOut.java
Normal file
5
src/main/java/eu/bitfield/recipes/core/step/StepOut.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public record StepOut(@JsonValue String name) {}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface StepRepository extends ReactiveCrudRepository<Step, Long> {
|
||||
// no batch support as of 2025-05-05
|
||||
// https://github.com/spring-projects/spring-data-r2dbc/issues/259
|
||||
// https://github.com/spring-projects/spring-framework/issues/33812
|
||||
default Flux<Step> addSteps(List<Step> steps) {
|
||||
return saveAll(steps);
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select * from step
|
||||
where recipe_id = $1
|
||||
order by id;
|
||||
""")
|
||||
Flux<Step> stepsByRecipeId(long recipeId);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from step where recipe_id = $1
|
||||
""")
|
||||
Mono<Long> removeStepsFromRecipe(long recipeId);
|
||||
}
|
||||
32
src/main/java/eu/bitfield/recipes/core/step/StepService.java
Normal file
32
src/main/java/eu/bitfield/recipes/core/step/StepService.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class StepService {
|
||||
private final StepRepository repo;
|
||||
|
||||
public Mono<List<Step>> addSteps(long recipeId, List<StepIn> stepsIn) {
|
||||
return flux(stepsIn).map(stepIn -> Step.initial(recipeId, stepIn.name()))
|
||||
.collectList()
|
||||
.flatMapMany(repo::addSteps)
|
||||
.collectList();
|
||||
}
|
||||
|
||||
public Mono<List<Step>> getSteps(long recipeId) {
|
||||
return repo.stepsByRecipeId(recipeId).collectList();
|
||||
}
|
||||
|
||||
public Mono<List<Step>> updateSteps(long recipeId, List<StepIn> stepsIn) {
|
||||
return repo.removeStepsFromRecipe(recipeId).then(addSteps(recipeId, stepsIn));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
public interface ToStepIn {
|
||||
StepIn toStepIn();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
public interface ToStepOut {
|
||||
StepOut toStepOut();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package eu.bitfield.recipes.log;
|
||||
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.boolex.EvaluationException;
|
||||
import ch.qos.logback.core.boolex.EventEvaluatorBase;
|
||||
import ch.qos.logback.core.boolex.Matcher;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class EventMatcherEvaluator extends EventEvaluatorBase<ILoggingEvent> {
|
||||
@Getter @Setter String messageRegex = ".*";
|
||||
@Getter @Setter String loggerNameRegex = ".*";
|
||||
Matcher messageMatcher = new Matcher();
|
||||
Matcher loggerNameMatcher = new Matcher();
|
||||
|
||||
public boolean evaluate(ILoggingEvent event) throws EvaluationException {
|
||||
return messageMatcher.matches(event.getFormattedMessage()) && loggerNameMatcher.matches(event.getLoggerName());
|
||||
}
|
||||
|
||||
public void start() {
|
||||
messageMatcher.setName("messageMatcher");
|
||||
messageMatcher.setRegex(messageRegex);
|
||||
messageMatcher.start();
|
||||
|
||||
loggerNameMatcher.setName("messageMatcher");
|
||||
loggerNameMatcher.setRegex(loggerNameRegex);
|
||||
loggerNameMatcher.start();
|
||||
|
||||
super.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
super.stop();
|
||||
|
||||
loggerNameMatcher.stop();
|
||||
|
||||
messageMatcher.stop();
|
||||
}
|
||||
}
|
||||
4
src/main/java/eu/bitfield/recipes/package-info.java
Normal file
4
src/main/java/eu/bitfield/recipes/package-info.java
Normal file
@@ -0,0 +1,4 @@
|
||||
@NonNullApi
|
||||
package eu.bitfield.recipes;
|
||||
|
||||
import org.springframework.lang.NonNullApi;
|
||||
74
src/main/java/eu/bitfield/recipes/util/AsyncUtils.java
Normal file
74
src/main/java/eu/bitfield/recipes/util/AsyncUtils.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.function.UnaryOperator;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.util.function.Function.*;
|
||||
|
||||
|
||||
public class AsyncUtils {
|
||||
public static <T, R> Function<T, R> supplyFn(Supplier<R> supplier) {
|
||||
return (T t) -> supplier.get();
|
||||
}
|
||||
|
||||
public static <T> UnaryOperator<Mono<T>> errIfEmpty(Supplier<? extends Throwable> err) {
|
||||
return (Mono<T> mono) -> mono.switchIfEmpty(err(err));
|
||||
}
|
||||
|
||||
public static <T> UnaryOperator<Mono<T>> errIf(Predicate<T> shouldErr, Function<T, ? extends Throwable> err) {
|
||||
return (Mono<T> mono) -> mono.flatMap(item -> shouldErr.test(item) ? err(err.apply(item)) : some(item));
|
||||
}
|
||||
|
||||
public static <T> UnaryOperator<Mono<T>> errIfNot(Predicate<T> isOk, Function<T, ? extends Throwable> err) {
|
||||
return (Mono<T> mono) -> mono.flatMap(item -> isOk.test(item) ? some(item) : err(err.apply(item)));
|
||||
}
|
||||
|
||||
public static <T> Mono<T> some(T some) {return Mono.just(some);}
|
||||
|
||||
@SafeVarargs
|
||||
public static <T> Flux<T> many(T... many) {return Flux.just(many);}
|
||||
|
||||
public static <T> Mono<T> none() {return Mono.empty();}
|
||||
|
||||
public static <T> Mono<T> err(Supplier<? extends Throwable> error) {return Mono.error(error);}
|
||||
|
||||
public static <T> Mono<T> err(Throwable error) {return Mono.error(error);}
|
||||
|
||||
public static Function<Mono<Boolean>, Mono<Boolean>> errIfTrue(Supplier<? extends Throwable> err) {
|
||||
return (Mono<Boolean> mono) -> mono.flatMap(ok -> ok ? err(err.get()) : some(false));
|
||||
}
|
||||
|
||||
public static Function<Mono<Boolean>, Mono<Boolean>> errIfFalse(Supplier<? extends Throwable> errorSupplier) {
|
||||
return (Mono<Boolean> mono) -> mono.flatMap(ok -> ok ? some(true) : err(errorSupplier.get()));
|
||||
}
|
||||
|
||||
public static <T> Mono<T> supply(Supplier<? extends T> supplier) {return Mono.fromSupplier(supplier);}
|
||||
|
||||
public static <T> Flux<T> flux(Iterable<? extends T> iterable) {return Flux.fromIterable(iterable);}
|
||||
|
||||
public static <T> Flux<T> flux(Stream<? extends T> stream) {return Flux.fromStream(stream);}
|
||||
|
||||
public static <T1, T2> UnaryOperator<Mono<T1>> chain(Function<T1, Mono<? extends T2>> visit) {
|
||||
return (Mono<T1> mono) -> mono.flatMap(t1 -> visit.apply(t1).thenReturn(t1));
|
||||
}
|
||||
|
||||
public static <T> Mono<T> defer(MonoSupplier<? extends T> supplier) {return Mono.defer(supplier);}
|
||||
|
||||
public static <T> Flux<T> defer(FluxSupplier<T> supplier) {return Flux.defer(supplier);}
|
||||
|
||||
public static <T, V> UnaryOperator<Flux<T>> takeUntilChanged(Function<? super T, ? super V> keySelector) {
|
||||
return (Flux<T> items) -> items.windowUntilChanged(keySelector).take(1).flatMap(identity());
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface MonoSupplier<T> extends Supplier<Mono<T>> {}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface FluxSupplier<T> extends Supplier<Flux<T>> {}
|
||||
}
|
||||
11
src/main/java/eu/bitfield/recipes/util/Chronology.java
Normal file
11
src/main/java/eu/bitfield/recipes/util/Chronology.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
@Component
|
||||
public class Chronology {
|
||||
public Instant now() {return Instant.now().truncatedTo(ChronoUnit.MICROS);}
|
||||
}
|
||||
33
src/main/java/eu/bitfield/recipes/util/CollectionUtils.java
Normal file
33
src/main/java/eu/bitfield/recipes/util/CollectionUtils.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collector;
|
||||
|
||||
import static java.util.stream.Collectors.*;
|
||||
|
||||
public class CollectionUtils {
|
||||
public static <K, V> MultiValueMap<K, V> multiValueMap() {
|
||||
return MultiValueMap.fromMultiValue(new HashMap<>());
|
||||
}
|
||||
|
||||
public static <K, V> MultiValueMap<K, V> multiValueMap(Map<K, List<V>> map) {
|
||||
return MultiValueMap.fromMultiValue(map);
|
||||
}
|
||||
|
||||
public static <T> Collector<T, ?, HashSet<T>> toHashSet() {
|
||||
return toCollection(HashSet::new);
|
||||
}
|
||||
|
||||
public static <T> Collector<T, ?, ArrayList<T>> toArrayList() {
|
||||
return toCollection(ArrayList::new);
|
||||
}
|
||||
|
||||
public static <K, V, R> Function<Map.Entry<K, V>, R> entryFn(BiFunction<K, V, R> fn) {
|
||||
return entry -> fn.apply(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
8
src/main/java/eu/bitfield/recipes/util/Entity.java
Normal file
8
src/main/java/eu/bitfield/recipes/util/Entity.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import org.springframework.data.annotation.Immutable;
|
||||
|
||||
@Immutable
|
||||
public interface Entity extends Id {
|
||||
long id();
|
||||
}
|
||||
25
src/main/java/eu/bitfield/recipes/util/ErrorUtils.java
Normal file
25
src/main/java/eu/bitfield/recipes/util/ErrorUtils.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.ErrorResponseException;
|
||||
|
||||
public class ErrorUtils {
|
||||
public static ErrorResponseException errorResponseException(Throwable ex, HttpStatusCode statusCode) {
|
||||
ErrorResponse response = errorResponse(ex, statusCode);
|
||||
return new ErrorResponseException(response.getStatusCode(), response.getBody(), ex);
|
||||
}
|
||||
|
||||
public static ErrorResponse errorResponse(Throwable ex, HttpStatusCode statusCode) {
|
||||
StringBuilder messageBuilder = new StringBuilder(ex.getMessage());
|
||||
Throwable cause = ex.getCause();
|
||||
while (cause != null) {
|
||||
messageBuilder.append(" > ").append(cause.getMessage());
|
||||
cause = cause.getCause();
|
||||
}
|
||||
String message = messageBuilder.toString();
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(statusCode, message);
|
||||
return ErrorResponse.builder(ex, problemDetail).build();
|
||||
}
|
||||
}
|
||||
6
src/main/java/eu/bitfield/recipes/util/Id.java
Normal file
6
src/main/java/eu/bitfield/recipes/util/Id.java
Normal file
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
public interface Id {
|
||||
long id();
|
||||
}
|
||||
|
||||
3
src/main/java/eu/bitfield/recipes/util/Pagination.java
Normal file
3
src/main/java/eu/bitfield/recipes/util/Pagination.java
Normal file
@@ -0,0 +1,3 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
public record Pagination(long limit, long offset) {}
|
||||
@@ -0,0 +1,39 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.Category;
|
||||
import eu.bitfield.recipes.core.category.CategoryOut;
|
||||
import eu.bitfield.recipes.core.ingredient.Ingredient;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientOut;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCat;
|
||||
import eu.bitfield.recipes.core.recipe.Recipe;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeOut;
|
||||
import eu.bitfield.recipes.core.step.Step;
|
||||
import eu.bitfield.recipes.core.step.StepOut;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record RecipeView(
|
||||
Recipe recipe,
|
||||
List<Category> categories,
|
||||
List<LinkRecCat> links,
|
||||
List<Ingredient> ingredients,
|
||||
List<Step> steps
|
||||
) implements ToRecipeViewOut {
|
||||
|
||||
public static RecipeViewOut createRecipeViewOut(
|
||||
Recipe recipe,
|
||||
List<Category> categories,
|
||||
List<Ingredient> ingredients,
|
||||
List<Step> steps)
|
||||
{
|
||||
RecipeOut recipeOut = recipe.toRecipeOut();
|
||||
List<CategoryOut> categoriesOut = categories.stream().map(Category::toCategoryOut).toList();
|
||||
List<IngredientOut> ingredientsOut = ingredients.stream().map(Ingredient::toIngredientOut).toList();
|
||||
List<StepOut> stepsOut = steps.stream().map(Step::toStepOut).toList();
|
||||
return new RecipeViewOut(recipeOut, categoriesOut, ingredientsOut, stepsOut);
|
||||
}
|
||||
|
||||
public RecipeViewOut toRecipeViewOut() {
|
||||
return createRecipeViewOut(recipe, categories, ingredients, steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||
import eu.bitfield.recipes.core.category.CategoryIn;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientIn;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeIn;
|
||||
import eu.bitfield.recipes.core.step.StepIn;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.With;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@With
|
||||
public record RecipeViewIn(
|
||||
@JsonUnwrapped @NotNull @Valid RecipeIn recipe,
|
||||
@NotNull List<@Valid @NotNull CategoryIn> categories,
|
||||
@NotEmpty List<@Valid @NotNull IngredientIn> ingredients,
|
||||
@NotEmpty List<@Valid @NotNull StepIn> steps
|
||||
) implements ToRecipeViewIn {
|
||||
public RecipeViewIn toRecipeViewIn() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||
import eu.bitfield.recipes.core.category.CategoryOut;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientOut;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeOut;
|
||||
import eu.bitfield.recipes.core.recipe.ToRecipeOut;
|
||||
import eu.bitfield.recipes.core.step.StepOut;
|
||||
import lombok.With;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@With
|
||||
public record RecipeViewOut(
|
||||
@JsonUnwrapped RecipeOut recipe,
|
||||
List<CategoryOut> categories,
|
||||
List<IngredientOut> ingredients,
|
||||
List<StepOut> steps
|
||||
) implements ToRecipeOut {
|
||||
public RecipeOut toRecipeOut() {
|
||||
return recipe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
public interface ToRecipeViewIn {
|
||||
RecipeViewIn toRecipeViewIn();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
public interface ToRecipeViewOut {
|
||||
RecipeViewOut toRecipeViewOut();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package eu.bitfield.recipes.view.registration;
|
||||
|
||||
import eu.bitfield.recipes.core.account.Account;
|
||||
import eu.bitfield.recipes.core.profile.Profile;
|
||||
|
||||
public record RegistrationView(
|
||||
Profile profile,
|
||||
Account account
|
||||
) {}
|
||||
31
src/main/resources/application.yaml
Normal file
31
src/main/resources/application.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
spring:
|
||||
application:
|
||||
name: recipes
|
||||
r2dbc:
|
||||
url: "r2dbc:postgresql://localhost:5432/recipes"
|
||||
username: "recipes"
|
||||
password: "dev_pw"
|
||||
main:
|
||||
banner-mode: "off"
|
||||
web-application-type: reactive
|
||||
webflux:
|
||||
problemdetails:
|
||||
enabled: true
|
||||
|
||||
# jackson:
|
||||
# serialization:
|
||||
# INDENT_OUTPUT: true
|
||||
#logging:
|
||||
# level:
|
||||
# io.r2dbc.pool: debug
|
||||
# io.r2dbc.postgresql.QUERY: debug
|
||||
# io.r2dbc.postgresql.PARAM: debug
|
||||
# org.springframework.r2dbc: debug
|
||||
# org.springframework.transaction: trace
|
||||
# org.springframework.r2dbc.connection.init.ScriptUtils: info
|
||||
# eu.bitfield.recipes.test.api.APICall: debug
|
||||
# eu.bitfield.recipes.api.ErrorResponseHandling: info
|
||||
# org.springframework.security: debug
|
||||
# reactor.netty.http.client: debug
|
||||
# reactor.netty.http.server: debug
|
||||
# org.springframework: debug
|
||||
26
src/main/resources/logback.xml
Normal file
26
src/main/resources/logback.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_LOG_THRESHOLD}</level>
|
||||
</filter>
|
||||
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
|
||||
<evaluator class="eu.bitfield.recipes.log.EventMatcherEvaluator">
|
||||
<loggerNameRegex>^io\.r2dbc\.postgresql\.QUERY$</loggerNameRegex>
|
||||
<messageRegex>Executing query: SHOW TRANSACTION ISOLATION LEVEL</messageRegex>
|
||||
</evaluator>
|
||||
<OnMatch>DENY</OnMatch>
|
||||
<OnMismatch>NEUTRAL</OnMismatch>
|
||||
</filter>
|
||||
<encoder>
|
||||
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
|
||||
<charset>${CONSOLE_LOG_CHARSET}</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
<logger name="org.springframework" level="WARN"/>
|
||||
</configuration>
|
||||
49
src/main/resources/schema.sql
Normal file
49
src/main/resources/schema.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
drop table if exists profile cascade;
|
||||
create table profile (
|
||||
id bigint primary key generated always as identity
|
||||
);
|
||||
|
||||
drop table if exists account cascade;
|
||||
create table account (
|
||||
id bigint primary key generated always as identity,
|
||||
profile_id bigint references profile on delete cascade,
|
||||
email text not null unique,
|
||||
password text not null
|
||||
);
|
||||
|
||||
drop table if exists recipe cascade;
|
||||
create table recipe (
|
||||
id bigint primary key generated always as identity,
|
||||
author_profile_id bigint not null references profile on delete cascade,
|
||||
name text not null,
|
||||
description text not null,
|
||||
changed_at timestamp not null
|
||||
);
|
||||
|
||||
drop table if exists category cascade;
|
||||
create table category (
|
||||
id bigint primary key generated always as identity,
|
||||
name text not null unique
|
||||
);
|
||||
|
||||
drop table if exists link_rec_cat cascade;
|
||||
create table link_rec_cat (
|
||||
id bigint primary key generated always as identity,
|
||||
recipe_id bigint not null references recipe on delete cascade,
|
||||
category_id bigint not null references category on delete cascade,
|
||||
unique (recipe_id, category_id)
|
||||
);
|
||||
|
||||
drop table if exists ingredient cascade;
|
||||
create table ingredient (
|
||||
id bigint primary key generated always as identity,
|
||||
recipe_id bigint references recipe on delete cascade,
|
||||
name text not null
|
||||
);
|
||||
|
||||
drop table if exists step cascade;
|
||||
create table step (
|
||||
id bigint primary key generated always as identity,
|
||||
recipe_id bigint references recipe on delete cascade,
|
||||
name text not null
|
||||
);
|
||||
178
src/test/java/eu/bitfield/recipes/api/APITest.java
Normal file
178
src/test/java/eu/bitfield/recipes/api/APITest.java
Normal file
@@ -0,0 +1,178 @@
|
||||
package eu.bitfield.recipes.api;
|
||||
|
||||
import eu.bitfield.recipes.api.recipe.RecipeEndpoint;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeOut;
|
||||
import eu.bitfield.recipes.test.api.APICalls;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeTemplate;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import io.r2dbc.spi.ConnectionFactory;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.InvalidEntity.*;
|
||||
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
@Getter @Accessors(fluent = true)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
public class APITest implements AccountQueries, RecipeViewQueries, APICalls {
|
||||
static final Pagination pagination = new Pagination(RecipeEndpoint.MAX_LIMIT, 0L);
|
||||
|
||||
@Autowired WebTestClient client;
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers().profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers().accountLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers().recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers().categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkLayer = layers().linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers().ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers().stepLayer();
|
||||
|
||||
static void cleanDatabase(ConnectionFactory connectionFactory, Resource cleanSql) {
|
||||
var resourceDatabasePopulator = new ResourceDatabasePopulator(cleanSql);
|
||||
resourceDatabasePopulator.addScript(cleanSql);
|
||||
resourceDatabasePopulator.populate(connectionFactory).block();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void afterAll(@Autowired ConnectionFactory connectionFactory,
|
||||
@Value("classpath:/clean.sql") Resource cleanSql)
|
||||
{
|
||||
cleanDatabase(connectionFactory, cleanSql);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach(@Autowired ConnectionFactory connectionFactory,
|
||||
@Value("classpath:/clean.sql") Resource cleanSql)
|
||||
{
|
||||
cleanDatabase(connectionFactory, cleanSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authentificationRequired() {
|
||||
long recipeId = freeId;
|
||||
RecipeViewIn viewIn = recipeViewGroup().toRecipeViewIn();
|
||||
addRecipeCall(viewIn).anonymous().unauthorized();
|
||||
updateRecipeCall(recipeId, viewIn).anonymous().unauthorized();
|
||||
removeRecipeCall(recipeId).anonymous().unauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountReregistration() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
|
||||
registerAccountCall(account).ok();
|
||||
registerAccountCall(account).badRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeCrudFlow() {
|
||||
AccountSlot author = accountSlot(profiles.ada).blank();
|
||||
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
|
||||
RecipeViewGroup group = recipeViewGroup(recipe);
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
|
||||
String updatedName = viewIn.recipe().name() + " v2";
|
||||
RecipeTemplate updatedRecipeTemplate = group.recipe().template().withName(updatedName);
|
||||
RecipeSlot updatedRecipe = recipeSlot().template(updatedRecipeTemplate).author(author.profile()).blank();
|
||||
RecipeViewGroup updatedGroup = recipeViewGroup(updatedRecipe);
|
||||
RecipeViewIn updatedViewIn = updatedGroup.toRecipeViewIn();
|
||||
|
||||
save(combinedSlots(group, updatedGroup));
|
||||
|
||||
registerAccountCall(author).ok();
|
||||
registerAccountCall(notAuthor).ok();
|
||||
|
||||
RecipeViewOut addedViewOut = addRecipeCall(viewIn).as(author).ok();
|
||||
assertThat(addedViewOut).isEqualTo(expectedViewOut(addedViewOut, group));
|
||||
|
||||
long recipeId = addedViewOut.recipe().id();
|
||||
|
||||
RecipeViewOut gotViewOut = getRecipeCall(recipeId).ok();
|
||||
assertThat(addedViewOut).isEqualTo(gotViewOut);
|
||||
|
||||
removeRecipeCall(recipeId).as(notAuthor).forbidden();
|
||||
getRecipeCall(recipeId).ok();
|
||||
|
||||
updateRecipeCall(recipeId, updatedViewIn).as(notAuthor).forbidden();
|
||||
gotViewOut = getRecipeCall(recipeId).ok();
|
||||
assertThat(addedViewOut).isEqualTo(gotViewOut);
|
||||
|
||||
RecipeViewOut updatedViewOut = updateRecipeCall(recipeId, updatedViewIn).as(author).ok();
|
||||
assertThat(updatedViewOut).isEqualTo(expectedViewOut(updatedViewOut, updatedGroup));
|
||||
|
||||
gotViewOut = getRecipeCall(recipeId).ok();
|
||||
assertThat(gotViewOut).isEqualTo(updatedViewOut);
|
||||
|
||||
removeRecipeCall(recipeId).as(author).ok();
|
||||
getRecipeCall(recipeId).notFound();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void recipeSearch() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
registerAccountCall(account).ok();
|
||||
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewOut> allViewsOut = allGroups.stream()
|
||||
.filter(group -> !group.ingredients().isEmpty() && !group.steps().isEmpty())
|
||||
.map(group -> addRecipeCall(group.toRecipeViewIn()).as(account).ok())
|
||||
.toList();
|
||||
allViewsOut = allViewsOut.reversed(); // newest recipe first
|
||||
|
||||
String categoryName = categorySlotWithLinkCount(2).blank().name();
|
||||
List<RecipeViewOut> viewsOut = allViewsOut.stream()
|
||||
.filter((RecipeViewOut viewOut) -> viewOut.categories().stream().anyMatch(hasCategoryName(categoryName)))
|
||||
.toList();
|
||||
List<RecipeViewOut> actualViewsOut = findRecipesCall().category(categoryName).pagination(pagination).ok();
|
||||
assertThat(actualViewsOut).isEqualTo(viewsOut);
|
||||
|
||||
String recipeName = "ma";
|
||||
viewsOut = allViewsOut.stream().filter(containsRecipeName(recipeName)).toList();
|
||||
actualViewsOut = findRecipesCall().recipe(recipeName).pagination(pagination).ok();
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
|
||||
}
|
||||
|
||||
|
||||
private RecipeViewOut expectedViewOut(RecipeViewOut actual, RecipeViewGroup group) {
|
||||
RecipeViewOut assumed = group.toRecipeViewOut();
|
||||
long actualRecipeId = actual.recipe().id();
|
||||
Instant actualChangedAt = actual.recipe().changedAt();
|
||||
RecipeOut expectedRecipe = assumed.recipe().withId(actualRecipeId).withChangedAt(actualChangedAt);
|
||||
return assumed.withRecipe(expectedRecipe);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package eu.bitfield.recipes.api.account;
|
||||
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddressIn;
|
||||
import eu.bitfield.recipes.auth.password.PasswordIn;
|
||||
import eu.bitfield.recipes.core.account.AccountIn;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import eu.bitfield.recipes.test.api.APICalls;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.view.registration.RegistrationView;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.TestUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Getter @Accessors(fluent = true)
|
||||
@WebFluxTest(value = AccountEndpoint.class, excludeAutoConfiguration = {
|
||||
ReactiveUserDetailsServiceAutoConfiguration.class,
|
||||
ReactiveSecurityAutoConfiguration.class
|
||||
})
|
||||
public class AccountEndpointTest implements AccountQueries, APICalls {
|
||||
@MockitoBean RegisterAccount registerAccount;
|
||||
@Autowired WebTestClient client;
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
|
||||
@Test
|
||||
void registerAccount_accountInEmailOk_ok() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
var registrationView = new RegistrationView(account.profile().saved(), account.saved());
|
||||
|
||||
when(registerAccount.registerAccount(accountIn)).thenReturn(some(registrationView));
|
||||
|
||||
registerAccountCall(accountIn).ok();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerAccount_accountInEmailInUse_badRequest() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
|
||||
var err = new RegisterAccount.EmailAlreadyInUse(new AccountService.EmailAlreadyInUse(account.email()));
|
||||
|
||||
when(registerAccount.registerAccount(accountIn)).thenReturn(err(err));
|
||||
|
||||
registerAccountCall(accountIn).badRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerAccount_invalidAccountIn_badRequest() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
EmailAddressIn invalidEmail = EmailAddressIn.ofUnchecked(accountIn.email().address() + "@@");
|
||||
PasswordIn invalidPassword = PasswordIn.ofUnchecked("shortpw");
|
||||
accountIn = accountIn.withEmail(invalidEmail).withPassword(invalidPassword);
|
||||
|
||||
when(registerAccount.registerAccount(any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
registerAccountCall(accountIn).badRequest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.bitfield.recipes.api.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.password.Password;
|
||||
import eu.bitfield.recipes.core.account.AccountIn;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import eu.bitfield.recipes.core.profile.ProfileService;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.view.registration.RegistrationView;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.TestUtils.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class RegisterAccountTest implements AccountQueries {
|
||||
ProfileService profileServ = mock(ProfileService.class);
|
||||
AccountService accountServ = mock(AccountService.class);
|
||||
RegisterAccount registerAccount = new RegisterAccount(profileServ, accountServ);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
|
||||
@Test
|
||||
void register_accountInWithUnusedEmail_accountRegistrationRegisterAccount() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
EmailAddress email = account.email();
|
||||
Password password = account.password();
|
||||
var registrationView = new RegistrationView(account.profile().saved(), account.saved());
|
||||
|
||||
when(accountServ.checkEmail(email)).thenReturn(some(email));
|
||||
when(profileServ.addProfile()).thenReturn(some(account.profile().saved()));
|
||||
when(accountServ.encode(account.passwordIn())).thenReturn(password);
|
||||
when(accountServ.addAccount(account.profile().id(), email, password)).thenReturn(some(account.saved()));
|
||||
|
||||
registerAccount.registerAccount(accountIn)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(registrationView)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerAccount_accountInWithUsedEmail_errorEmailAlreadyInUse() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
EmailAddress email = account.email();
|
||||
|
||||
when(accountServ.checkEmail(email)).thenReturn(err(new AccountService.EmailAlreadyInUse(email)));
|
||||
when(profileServ.addProfile()).thenReturn(noSubscriptionMono());
|
||||
when(accountServ.addAccount(anyLong(), any(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
registerAccount.registerAccount(accountIn)
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(RegisterAccount.EmailAlreadyInUse.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Chronology;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
|
||||
public class AddRecipeTest implements RecipeViewQueries {
|
||||
RecipeService recipeServ = mock(RecipeService.class);
|
||||
CategoryService categoryServ = mock(CategoryService.class);
|
||||
LinkRecCatService linkServ = mock(LinkRecCatService.class);
|
||||
IngredientService ingredientServ = mock(IngredientService.class);
|
||||
StepService stepServ = mock(StepService.class);
|
||||
Chronology time = mock(Chronology.class);
|
||||
AddRecipe addRecipe = new AddRecipe(recipeServ, categoryServ, linkServ, ingredientServ, stepServ, time);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void addRecipe_anyRecipeViewIn_recipeViewAddRecipe() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeSlot recipe = group.recipe();
|
||||
Instant createdAt = recipe.saved().changedAt();
|
||||
|
||||
when(time.now()).thenReturn(createdAt);
|
||||
when(recipeServ.addRecipe(recipe.author().id(), viewIn.recipe(), createdAt)).thenReturn(some(recipe.saved()));
|
||||
when(categoryServ.addCategories(viewIn.categories())).thenReturn(some(view.categories()));
|
||||
when(linkServ.addLinks(recipe.id(), view.categories())).thenReturn(some(view.links()));
|
||||
when(ingredientServ.addIngredients(recipe.id(), viewIn.ingredients())).thenReturn(some(view.ingredients()));
|
||||
when(stepServ.addSteps(recipe.id(), viewIn.steps())).thenReturn(some(view.steps()));
|
||||
|
||||
addRecipe.addRecipe(viewIn, recipe.author().id())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(view)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.To.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class FindRecipesTest implements RecipeViewQueries {
|
||||
RecipeService recipeServ = mock(RecipeService.class);
|
||||
GetRecipe getRecipe = mock(GetRecipe.class);
|
||||
FindRecipes findRecipes = new FindRecipes(recipeServ, getRecipe);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void findRecipes_categoryName_recipeViewsOut() {
|
||||
String categoryName = categorySlotWithLinkCount(2).blank().name();
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewGroup> groups = allGroups.stream()
|
||||
.filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName)))
|
||||
.sorted(orderByRecipeChangedAtDesc())
|
||||
.toList();
|
||||
List<RecipeViewOut> savedViewsOut = groups.stream().map(toRecipeViewOut).toList();
|
||||
List<Long> recipeIds = groups.stream().map(toRecipe).map(toId).toList();
|
||||
long recipeCount = recipeIds.size();
|
||||
var pagination = new Pagination(recipeCount, 0);
|
||||
String recipeName = null;
|
||||
|
||||
for (var group : allGroups) {
|
||||
when(getRecipe.getRecipe(group.recipe().id())).thenReturn(some(group.toRecipeViewOut()));
|
||||
}
|
||||
when(recipeServ.findRecipeIds(categoryName, recipeName, pagination)).thenReturn(flux(recipeIds));
|
||||
|
||||
findRecipes.findRecipes(categoryName, recipeName, pagination)
|
||||
.as(StepVerifier::create)
|
||||
.assertNext((List<RecipeViewOut> actualViewsOut) -> {
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_recipeNamePart_recipeViewsOut() {
|
||||
String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewGroup> groups = allGroups.stream()
|
||||
.filter(containsRecipeName(recipeName))
|
||||
.sorted(orderByRecipeChangedAtDesc())
|
||||
.toList();
|
||||
List<RecipeViewOut> savedViewsOut = groups.stream().map(toRecipeViewOut).toList();
|
||||
List<Long> recipeIds = groups.stream().map(toRecipe).map(toId).toList();
|
||||
long recipeCount = recipeIds.size();
|
||||
var pagination = new Pagination(recipeCount, 0);
|
||||
String categoryName = null;
|
||||
|
||||
for (var group : allGroups) {
|
||||
when(getRecipe.getRecipe(group.recipe().id())).thenReturn(some(group.toRecipeViewOut()));
|
||||
}
|
||||
when(recipeServ.findRecipeIds(categoryName, recipeName, pagination)).thenReturn(flux(recipeIds));
|
||||
|
||||
findRecipes.findRecipes(categoryName, recipeName, pagination)
|
||||
.as(StepVerifier::create)
|
||||
.assertNext((List<RecipeViewOut> actualViewsOut) -> {
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.InvalidEntity.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class GetRecipeTest implements RecipeViewQueries {
|
||||
RecipeService recipeServ = mock(RecipeService.class);
|
||||
CategoryService categoryServ = mock(CategoryService.class);
|
||||
IngredientService ingredientServ = mock(IngredientService.class);
|
||||
StepService stepServ = mock(StepService.class);
|
||||
GetRecipe getRecipe = new GetRecipe(recipeServ, categoryServ, ingredientServ, stepServ);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void getRecipe_nonExistentRecipeId_errorNotFound() {
|
||||
long recipeId = freeId;
|
||||
|
||||
when(recipeServ.getRecipe(recipeId)).thenReturn(err(new RecipeService.RecipeNotFound(freeId)));
|
||||
|
||||
getRecipe.getRecipe(recipeId)
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(GetRecipe.NotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecipe_existentRecipeId_recipeViewOutGetRecipe() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
RecipeSlot recipe = group.recipe();
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeViewOut viewOut = group.toRecipeViewOut();
|
||||
|
||||
when(recipeServ.getRecipe(recipe.id())).thenReturn(some(view.recipe()));
|
||||
when(categoryServ.getCategories(recipe.id())).thenReturn(some(view.categories()));
|
||||
when(ingredientServ.getIngredients(recipe.id())).thenReturn(some(view.ingredients()));
|
||||
when(stepServ.getSteps(recipe.id())).thenReturn(some(view.steps()));
|
||||
|
||||
getRecipe.getRecipe(recipe.id())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(viewOut)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.auth.ProfileIdentityAccess;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.test.InvalidEntity;
|
||||
import eu.bitfield.recipes.test.api.APICalls;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.api.recipe.RecipeEndpoint.*;
|
||||
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.TestUtils.*;
|
||||
import static eu.bitfield.recipes.util.To.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@WebFluxTest(value = RecipeEndpoint.class, excludeAutoConfiguration = {
|
||||
ReactiveUserDetailsServiceAutoConfiguration.class,
|
||||
ReactiveSecurityAutoConfiguration.class
|
||||
})
|
||||
@Getter @Accessors(fluent = true)
|
||||
public class RecipeEndpointTest implements RecipeViewQueries, AccountQueries, APICalls {
|
||||
static final Pagination pagination = new Pagination(RecipeEndpoint.MAX_LIMIT, 0L);
|
||||
static final long freeRecipeId = InvalidEntity.freeId;
|
||||
|
||||
@MockitoBean AddRecipe addRecipe;
|
||||
@MockitoBean GetRecipe getRecipe;
|
||||
@MockitoBean UpdateRecipe updateRecipe;
|
||||
@MockitoBean RemoveRecipe removeRecipe;
|
||||
@MockitoBean FindRecipes findRecipes;
|
||||
@MockitoBean ProfileIdentityAccess profileIdentity;
|
||||
|
||||
@Autowired WebTestClient client;
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void addRecipe_validRecipeIn_okRecipeId() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
|
||||
save(slots(group).add(author));
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeViewOut viewOut = group.toRecipeViewOut();
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(author.profile().id()));
|
||||
when(addRecipe.addRecipe(viewIn, author.profile().id())).thenReturn(some(view));
|
||||
|
||||
RecipeViewOut actualViewOut = addRecipeCall(viewIn).as(author).ok();
|
||||
assertThat(actualViewOut).isEqualTo(viewOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRecipe_invalidRecipeIn_badRequest() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
|
||||
save(slots(group).add(author));
|
||||
RecipeViewIn viewIn = invalid(group.toRecipeViewIn());
|
||||
|
||||
when(profileIdentity.id()).thenReturn(noSubscriptionMono());
|
||||
when(addRecipe.addRecipe(any(), anyLong())).thenReturn(noSubscriptionMono());
|
||||
|
||||
addRecipeCall(viewIn).as(author).badRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecipe_existentRecipeId_okRecipeViewOut() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
RecipeViewOut viewOut = group.toRecipeViewOut();
|
||||
long recipeId = group.recipe().id();
|
||||
|
||||
when(getRecipe.getRecipe(recipeId)).thenReturn(some(viewOut));
|
||||
|
||||
RecipeViewOut actualViewOut = getRecipeCall(recipeId).ok();
|
||||
assertThat(actualViewOut).isEqualTo(viewOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecipe_nonExistentRecipeId_notFound() {
|
||||
var err = new GetRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
|
||||
|
||||
when(getRecipe.getRecipe(freeRecipeId)).thenReturn(err(err));
|
||||
|
||||
getRecipeCall(freeRecipeId).notFound();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_existentRecipeId$validRecipeViewIn$author_ok() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
|
||||
save(slots(group).add(author));
|
||||
RecipeSlot recipe = group.recipe();
|
||||
long profileId = recipe.author().id();
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeViewOut viewOut = group.toRecipeViewOut();
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(profileId));
|
||||
when(updateRecipe.updateRecipe(recipe.id(), viewIn, profileId)).thenReturn(some(view));
|
||||
|
||||
RecipeViewOut actualViewOut = updateRecipeCall(recipe.id(), viewIn).as(author).ok();
|
||||
assertThat(actualViewOut).isEqualTo(viewOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_nonExistentRecipeId$validRecipeViewIn_notFound() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot account = accountSlot().profile(group.recipe().author()).blank();
|
||||
save(slots(group).add(account));
|
||||
long profileId = group.recipe().author().id();
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
var err = new UpdateRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(profileId));
|
||||
when(updateRecipe.updateRecipe(freeRecipeId, viewIn, profileId)).thenReturn(err(err));
|
||||
|
||||
updateRecipeCall(freeRecipeId, viewIn).as(account).notFound();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_existentRecipeId$ValidRecipeViewIn$notAuthor_forbidden() {
|
||||
AccountSlot author = accountSlot(profiles.ada).blank();
|
||||
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
|
||||
RecipeViewGroup group = recipeViewGroup(recipe);
|
||||
save(slots(group).add(author, notAuthor));
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
long recipeId = group.recipe().id();
|
||||
var err = new UpdateRecipe.Forbidden(new RecipeService.UpdateRecipeForbidden(recipeId));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(notAuthor.profile().id()));
|
||||
when(updateRecipe.updateRecipe(recipeId, viewIn, notAuthor.profile().id())).thenReturn(err(err));
|
||||
|
||||
updateRecipeCall(recipeId, viewIn).as(notAuthor).forbidden();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_invalidRecipeViewIn_badRequest() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot account = accountSlot().profile(group.recipe().author()).blank();
|
||||
RecipeViewIn viewIn = invalid(group.toRecipeViewIn());
|
||||
|
||||
when(profileIdentity.id()).thenReturn(noSubscriptionMono());
|
||||
when(updateRecipe.updateRecipe(anyLong(), any(), anyLong())).thenReturn(noSubscriptionMono());
|
||||
|
||||
updateRecipeCall(freeRecipeId, viewIn).as(account).badRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_existentRecipeId$author_ok() {
|
||||
AccountSlot author = accountSlot().blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
|
||||
save(slot(author).add(recipe));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(author.profile().id()));
|
||||
when(removeRecipe.removeRecipe(recipe.id(), author.profile().id())).thenReturn(some(true));
|
||||
|
||||
removeRecipeCall(recipe.id()).as(author).ok();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_nonExistentRecipeId_notFound() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
var err = new RemoveRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(account.profile().id()));
|
||||
when(removeRecipe.removeRecipe(freeRecipeId, account.profile().id())).thenReturn(err(err));
|
||||
|
||||
removeRecipeCall(freeRecipeId).as(account).notFound();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_existentRecipeId$notAuthor_forbidden() {
|
||||
AccountSlot author = accountSlot(profiles.ada).blank();
|
||||
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
|
||||
save(slot(recipe).add(author, notAuthor));
|
||||
var err = new RemoveRecipe.Forbidden(new RecipeService.RemoveRecipeForbidden(recipe.id()));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(notAuthor.profile().id()));
|
||||
when(removeRecipe.removeRecipe(recipe.id(), notAuthor.profile().id())).thenReturn(err(err));
|
||||
|
||||
removeRecipeCall(recipe.id()).as(notAuthor).forbidden();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_someCategoryName$noRecipeName_okAllRecipeViewOut() {
|
||||
String categoryName = categorySlotWithLinkCount(2).blank().name();
|
||||
String recipeName = null;
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewGroup> groups = allGroups.stream()
|
||||
.filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName)))
|
||||
.sorted(orderByRecipeChangedAtDesc())
|
||||
.toList();
|
||||
List<RecipeViewOut> viewsOut = groups.stream().map(toRecipeViewOut).toList();
|
||||
|
||||
when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut));
|
||||
|
||||
List<RecipeViewOut> actualViewsOut = findRecipesCall().category(categoryName).pagination(pagination).ok();
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_noCategoryName$someRecipeName_okAllRecipeViewOut() {
|
||||
String categoryName = null;
|
||||
String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewGroup> groups = allGroups.stream()
|
||||
.filter(containsRecipeName(recipeName))
|
||||
.sorted(orderByRecipeChangedAtDesc())
|
||||
.toList();
|
||||
List<RecipeViewOut> viewsOut = groups.stream().map(RecipeViewGroup::toRecipeViewOut).toList();
|
||||
|
||||
when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut));
|
||||
|
||||
List<RecipeViewOut> actualViewsOut = findRecipesCall().recipe(recipeName).pagination(pagination).ok();
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_invalidLimit_badRequest() {
|
||||
when(findRecipes.findRecipes(any(), any(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
for (long limit : List.of(MIN_LIMIT - 1, MAX_LIMIT + 1)) {
|
||||
findRecipesCall().recipe("any").limit(limit).offset(0L).badRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_invalidOffset_badRequestProblemDetail() {
|
||||
when(findRecipes.findRecipes(any(), any(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
findRecipesCall().recipe("any").limit(MAX_LIMIT).offset(-1L).badRequest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileSlot;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeQueries;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.InvalidEntity.*;
|
||||
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class RemoveRecipeTest implements RecipeQueries {
|
||||
RecipeService serv = mock(RecipeService.class);
|
||||
RemoveRecipe removeRecipe = new RemoveRecipe(serv);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
|
||||
@Test
|
||||
void removeRecipe_nonExistentRecipeId_errorNotFound() {
|
||||
long recipeId = freeId;
|
||||
ProfileSlot profile = profileSlot().blank();
|
||||
save(slot(profile));
|
||||
|
||||
when(serv.removeRecipe(recipeId, profile.id())).thenReturn(err(new RecipeService.RecipeNotFound(recipeId)));
|
||||
|
||||
removeRecipe.removeRecipe(recipeId, profile.id())
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(RemoveRecipe.NotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_notAuthorProfileId_errorForbidden() {
|
||||
ProfileSlot author = profileSlot(profiles.ada).blank();
|
||||
ProfileSlot notAuthor = profileSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author).blank();
|
||||
save(slots(author, notAuthor).add(recipe));
|
||||
|
||||
when(serv.removeRecipe(recipe.id(), notAuthor.id())).thenReturn(err(new RemoveRecipeForbidden(recipe.id())));
|
||||
|
||||
removeRecipe.removeRecipe(recipe.id(), notAuthor.id())
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(RemoveRecipe.Forbidden.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_anyRecipeIn_removedRecipe() {
|
||||
RecipeSlot recipe = recipeSlot().blank();
|
||||
save(slot(recipe));
|
||||
|
||||
when(serv.removeRecipe(recipe.id(), recipe.author().id())).thenReturn(some(true));
|
||||
|
||||
removeRecipe.removeRecipe(recipe.id(), recipe.author().id())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.Ingredient;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatService;
|
||||
import eu.bitfield.recipes.core.recipe.Recipe;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeIn;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileSlot;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Chronology;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.InvalidEntity.*;
|
||||
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.TestUtils.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class UpdateRecipeTest implements RecipeViewQueries {
|
||||
RecipeService recipeServ = mock(RecipeService.class);
|
||||
CategoryService categoryServ = mock(CategoryService.class);
|
||||
LinkRecCatService linkServ = mock(LinkRecCatService.class);
|
||||
StepService stepServ = mock(StepService.class);
|
||||
IngredientService ingredientServ = mock(IngredientService.class);
|
||||
Chronology time = mock(Chronology.class);
|
||||
UpdateRecipe updateRecipe = new UpdateRecipe(recipeServ, categoryServ, linkServ, stepServ, ingredientServ, time);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void updateRecipe_nonExistentRecipeId_errorNotFound() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
long recipeId = freeId;
|
||||
long profileId = group.recipe().author().id();
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
Instant changedAt = realTime().now();
|
||||
var err = new RecipeNotFound(recipeId);
|
||||
|
||||
when(time.now()).thenReturn(changedAt);
|
||||
when(recipeServ.updateRecipe(recipeId, profileId, viewIn.recipe(), changedAt)).thenReturn(err(err));
|
||||
when(categoryServ.addCategories(any())).thenReturn(noSubscriptionMono());
|
||||
when(linkServ.updateLinks(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
when(stepServ.updateSteps(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
when(ingredientServ.updateIngredients(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
updateRecipe.updateRecipe(recipeId, viewIn, profileId)
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(UpdateRecipe.NotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_notAuthorProfileId_errorForbidden() {
|
||||
ProfileSlot author = profileSlot(profiles.ada).blank();
|
||||
ProfileSlot notAuthor = profileSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author).blank();
|
||||
RecipeViewGroup group = recipeViewGroup(recipe);
|
||||
save(slots(group).add(author, notAuthor));
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
RecipeIn recipeIn = viewIn.recipe();
|
||||
Instant changedAt = realTime().now();
|
||||
var err = new UpdateRecipeForbidden(recipe.id());
|
||||
|
||||
when(time.now()).thenReturn(changedAt);
|
||||
when(recipeServ.updateRecipe(recipe.id(), notAuthor.id(), recipeIn, changedAt)).thenReturn(err(err));
|
||||
when(categoryServ.addCategories(any())).thenReturn(noSubscriptionMono());
|
||||
when(linkServ.updateLinks(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
when(stepServ.updateSteps(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
when(ingredientServ.updateIngredients(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
updateRecipe.updateRecipe(recipe.id(), viewIn, notAuthor.id())
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(UpdateRecipe.Forbidden.class);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_anyRecipeIn_recipeView() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
Recipe recipe = view.recipe();
|
||||
List<Ingredient> ingredients = view.ingredients();
|
||||
long recipeId = recipe.id();
|
||||
long profileId = recipe.authorProfileId();
|
||||
Instant changedAt = recipe.changedAt();
|
||||
|
||||
when(time.now()).thenReturn(changedAt);
|
||||
when(recipeServ.updateRecipe(recipeId, profileId, viewIn.recipe(), changedAt)).thenReturn(some(recipe));
|
||||
when(categoryServ.addCategories(viewIn.categories())).thenReturn(some(view.categories()));
|
||||
when(linkServ.updateLinks(recipeId, view.categories())).thenReturn(some(view.links()));
|
||||
when(stepServ.updateSteps(recipeId, viewIn.steps())).thenReturn(some(view.steps()));
|
||||
when(ingredientServ.updateIngredients(recipeId, viewIn.ingredients())).thenReturn(some(ingredients));
|
||||
|
||||
updateRecipe.updateRecipe(recipeId, viewIn, profileId)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(view)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class AccountPrincipalServiceTest implements AccountQueries {
|
||||
AccountService accountServ = mock(AccountService.class);
|
||||
AccountPrincipalService serv = new AccountPrincipalService(accountServ);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
|
||||
@Test
|
||||
void findByUserName_existentAccountEmail_accountPrincipal() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
String address = account.emailIn().address().toUpperCase();
|
||||
var principal = new AccountPrincipal(account.saved());
|
||||
|
||||
when(accountServ.getAccount(account.email())).thenReturn(some(account.saved()));
|
||||
|
||||
serv.findByUsername(address)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(principal)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUserName_nonExistentAccountEmail_none() {
|
||||
EmailAddress email = unusedEmail;
|
||||
|
||||
when(accountServ.getAccount(email)).thenReturn(none());
|
||||
|
||||
serv.findByUsername(email.address())
|
||||
.as(StepVerifier::create)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.config.DatabaseConfiguration;
|
||||
import eu.bitfield.recipes.core.profile.ProfileRepository;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.AsyncLayerFactory;
|
||||
import eu.bitfield.recipes.test.data.AsyncRootStorage;
|
||||
import eu.bitfield.recipes.util.Transaction;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.reactive.TransactionalOperator;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.AsyncPersistence.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.To.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Import(DatabaseConfiguration.class) @DataR2dbcTest
|
||||
class AccountRepositoryTest implements AccountQueries {
|
||||
|
||||
final AccountRepository repo;
|
||||
|
||||
final @Delegate AsyncRootStorage rootStorage;
|
||||
final @Delegate ProfileLayer profileLayer;
|
||||
final @Delegate AccountLayer accountLayer;
|
||||
final Transaction transaction;
|
||||
|
||||
@Autowired
|
||||
public AccountRepositoryTest(
|
||||
ProfileRepository profileRepo,
|
||||
AccountRepository accountRepo,
|
||||
TransactionalOperator transactionalOperator)
|
||||
{
|
||||
this.repo = accountRepo;
|
||||
this.rootStorage = new AsyncRootStorage();
|
||||
var layers = new AsyncLayerFactory(rootStorage);
|
||||
this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo));
|
||||
this.accountLayer = layers.accountLayer(asyncPersistence(accountRepo));
|
||||
this.transaction = new Transaction(transactionalOperator);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAccount_validInitialAccount_savedAccount() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
@NoArgsConstructor @AllArgsConstructor @Setter @Accessors(fluent = true)
|
||||
class Check {
|
||||
Account initial, actual, saved;
|
||||
}
|
||||
|
||||
var checks = supply(() -> new Check().initial(account.initial()))
|
||||
.zipWhen((Check c) -> repo.addAccount(c.initial), Check::actual)
|
||||
.zipWhen((Check c) -> repo.findById(c.actual.id()), Check::saved);
|
||||
|
||||
init(slot(account))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext((Check c) -> {
|
||||
assertThat(c.actual.id()).isNotZero();
|
||||
assertThat(c.actual).usingRecursiveComparison().ignoringFields(Account.Fields.id).isEqualTo(c.initial);
|
||||
assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAccount_invalidAccountNonExistentProfileId_errorForeignKeyConstraintViolation() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
|
||||
var checks = defer(() -> repo.addAccount(account.initial()));
|
||||
|
||||
invalidate(account.profile());
|
||||
init(slot(account))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.verifyError(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountByEmail_usedAccountEmail_account() {
|
||||
List<AccountSlot> accounts = accountSlots().limit(2).map(to::blank).toList();
|
||||
AccountSlot account = accounts.getFirst();
|
||||
|
||||
var checks = defer(() -> repo.accountByEmail(account.email()))
|
||||
.zipWhen((Account actual) -> some(account.saved()));
|
||||
|
||||
save(slots(accounts))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext(consumer((Account actual, Account saved) -> {
|
||||
assertThat(actual).usingRecursiveComparison().isEqualTo(saved);
|
||||
}))
|
||||
.verifyComplete();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountByEmail_unusedEmail_none() {
|
||||
var checks = repo.accountByEmail(unusedEmail);
|
||||
|
||||
checks.as(transaction::rollbackVerify)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isEmailUsed_usedAccountEmail_true() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
|
||||
var checks = defer(() -> repo.isEmailUsed(account.email()));
|
||||
|
||||
save(slot(account))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isEmailUsed_unusedAccountEmail_false() {
|
||||
var checks = repo.isEmailUsed(unusedEmail);
|
||||
|
||||
checks.as(transaction::rollbackVerify)
|
||||
.expectNext(false)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class AccountServiceTest implements AccountQueries {
|
||||
AccountRepository repo = mock(AccountRepository.class);
|
||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
||||
AccountService serv = new AccountService(repo, encoder);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
|
||||
@Test
|
||||
public void addAccount_existentProfileId_savedAccount() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
|
||||
when(encoder.encode(account.passwordIn().raw())).thenReturn(account.password().encoded());
|
||||
when(repo.addAccount(account.initial())).thenReturn(some(account.saved()));
|
||||
|
||||
serv.addAccount(account.profile().id(), account.email(), account.password())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(account.saved())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void getAccount_existentAccountEmail_account() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
|
||||
when(repo.accountByEmail(account.email())).thenReturn(some(account.saved()));
|
||||
|
||||
serv.getAccount(account.email())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(account.saved()).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkEmailAvailable_usedEmail_errorEmailAlreadyInUse() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
|
||||
when(repo.isEmailUsed(account.email())).thenReturn(some(true));
|
||||
|
||||
serv.checkEmail(account.email())
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(AccountService.EmailAlreadyInUse.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkEmailAvailable_unusedEmail_none() {
|
||||
EmailAddress email = unusedEmail;
|
||||
|
||||
when(repo.isEmailUsed(email)).thenReturn(some(false));
|
||||
|
||||
serv.checkEmail(email)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(email)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import eu.bitfield.recipes.config.DatabaseConfiguration;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatRepository;
|
||||
import eu.bitfield.recipes.core.profile.ProfileRepository;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeRepository;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.category.CategorySlot;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatQueries;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.data.AsyncLayerFactory;
|
||||
import eu.bitfield.recipes.test.data.AsyncRootStorage;
|
||||
import eu.bitfield.recipes.util.Transaction;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.reactive.TransactionalOperator;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.AsyncPersistence.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.To.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Import(DatabaseConfiguration.class) @DataR2dbcTest
|
||||
class CategoryRepositoryTest implements LinkRecCatQueries {
|
||||
final CategoryRepository repo;
|
||||
|
||||
final @Delegate AsyncRootStorage rootStorage;
|
||||
final @Delegate ProfileLayer profileLayer;
|
||||
final @Delegate RecipeLayer recipeLayer;
|
||||
final @Delegate CategoryLayer categoryLayer;
|
||||
final @Delegate LinkRecCatLayer linkLayer;
|
||||
final Transaction transaction;
|
||||
|
||||
@Autowired
|
||||
public CategoryRepositoryTest(
|
||||
ProfileRepository profileRepo,
|
||||
CategoryRepository categoryRepo,
|
||||
RecipeRepository recipeRepo,
|
||||
LinkRecCatRepository linkRepo,
|
||||
TransactionalOperator transactionalOp)
|
||||
{
|
||||
this.repo = categoryRepo;
|
||||
this.rootStorage = new AsyncRootStorage();
|
||||
var layerFactory = new AsyncLayerFactory(rootStorage);
|
||||
this.profileLayer = layerFactory.profileLayer(asyncPersistence(profileRepo));
|
||||
this.categoryLayer = layerFactory.categoryLayer(asyncPersistence(categoryRepo));
|
||||
this.recipeLayer = layerFactory.recipeLayer(asyncPersistence(recipeRepo));
|
||||
this.linkLayer = layerFactory.linkRecCatLayer(asyncPersistence(linkRepo));
|
||||
this.transaction = new Transaction(transactionalOp);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addCategory_validInitialCategory_savedCategory() {
|
||||
CategorySlot category = categorySlot().blank();
|
||||
|
||||
var checks = defer(() -> repo.addCategory(category.initial()))
|
||||
.zipWhen((Category actual) -> repo.findById(actual.id()));
|
||||
|
||||
init(slot(category))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext(consumer((Category actual, Category saved) -> {
|
||||
Category initial = category.initial();
|
||||
assertThat(actual.id()).isNotZero();
|
||||
assertThat(actual).usingRecursiveComparison().ignoringFields(Category.Fields.id).isEqualTo(initial);
|
||||
assertThat(actual).usingRecursiveComparison().isEqualTo(saved);
|
||||
}))
|
||||
.verifyComplete();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void addCategory_initialCategoryExistentName_errorUniqueNameConstraintException() {
|
||||
CategorySlot category = categorySlot().blank();
|
||||
|
||||
var checks = defer(() -> repo.addCategory(category.initial()));
|
||||
|
||||
save(slot(category))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.verifyError(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void categoriesOutByRecipeId_existentRecipeId_categoriesOut() {
|
||||
List<RecipeSlot> recipes = recipeSlotsWithLinkCounts(2, 1).map(to::blank).toList();
|
||||
RecipeSlot recipe = recipes.getFirst();
|
||||
List<CategorySlot> categories = linkedCategorySlots(recipe).map(to::blank).toList();
|
||||
List<LinkRecCatSlot> allLinks = recipes.stream().flatMap(this::linkRecCatSlots).map(to::blank).toList();
|
||||
|
||||
var checks = defer(() -> repo.categoriesByRecipeId(recipe.id()).collectList());
|
||||
|
||||
save(slots(allLinks))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext((List<Category> actual) -> {
|
||||
List<Category> saved = categories.stream().map(to::saved).toList();
|
||||
assertThat(actual).containsExactlyInAnyOrderElementsOf(saved);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void categoryIdByName_existentCategoryName_categoryId() {
|
||||
List<CategorySlot> categories = categorySlots().limit(2).map(to::blank).toList();
|
||||
CategorySlot category = categories.getFirst();
|
||||
|
||||
var checks = defer(() -> repo.categoryIdByName(category.name()));
|
||||
|
||||
save(slot(category))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext((Long actualId) -> {
|
||||
assertThat(actualId).isEqualTo(category.id());
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void categoryIdByName_nonExistentCategoryName_none() {
|
||||
var checks = repo.categoryIdByName("not_a_category");
|
||||
|
||||
checks.as(transaction::rollbackVerify)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user