feat!: initial prototype

This commit is contained in:
2025-08-12 17:25:52 +02:00
commit d38edb74b0
213 changed files with 8977 additions and 0 deletions

43
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
rootProject.name = "recipe-api"

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View 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();
}
}

View File

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

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.auth;
public interface ProfileIdentity {
long profileId();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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());
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.core.account;
public interface ToAccountIn {
AccountIn toAccountIn();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.core.category;
public interface ToCategoryIn {
CategoryIn toCategoryIn();
}

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.core.category;
public interface ToCategoryOut {
CategoryOut toCategoryOut();
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.core.ingredient;
import com.fasterxml.jackson.annotation.JsonValue;
public record IngredientOut(@JsonValue String name) {}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package eu.bitfield.recipes.core.ingredient;
public interface ToIngredientIn {
IngredientIn toIngredientIn();
}

View File

@@ -0,0 +1,6 @@
package eu.bitfield.recipes.core.ingredient;
public interface ToIngredientOut {
IngredientOut toIngredientOut();
}

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

View File

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

View File

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

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

View File

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

View File

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

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

View 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
) {
}

View 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
) {}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package eu.bitfield.recipes.core.recipe;
public interface ToRecipeIn {
RecipeIn toRecipeIn();
}

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.core.recipe;
public interface ToRecipeOut {
RecipeOut toRecipeOut();
}

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

View 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) {
}

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.core.step;
import com.fasterxml.jackson.annotation.JsonValue;
public record StepOut(@JsonValue String name) {}

View File

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

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

View File

@@ -0,0 +1,6 @@
package eu.bitfield.recipes.core.step;
public interface ToStepIn {
StepIn toStepIn();
}

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.core.step;
public interface ToStepOut {
StepOut toStepOut();
}

View File

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

View File

@@ -0,0 +1,4 @@
@NonNullApi
package eu.bitfield.recipes;
import org.springframework.lang.NonNullApi;

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

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

View 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());
}
}

View File

@@ -0,0 +1,8 @@
package eu.bitfield.recipes.util;
import org.springframework.data.annotation.Immutable;
@Immutable
public interface Entity extends Id {
long id();
}

View 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();
}
}

View File

@@ -0,0 +1,6 @@
package eu.bitfield.recipes.util;
public interface Id {
long id();
}

View File

@@ -0,0 +1,3 @@
package eu.bitfield.recipes.util;
public record Pagination(long limit, long offset) {}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.view.recipe;
public interface ToRecipeViewIn {
RecipeViewIn toRecipeViewIn();
}

View File

@@ -0,0 +1,5 @@
package eu.bitfield.recipes.view.recipe;
public interface ToRecipeViewOut {
RecipeViewOut toRecipeViewOut();
}

View File

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

View 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

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

View 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
);

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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