commit d38edb74b01300992c9e3896a1e93b54a0f8966b Author: Tobias Hänel Date: Tue Aug 12 17:25:52 2025 +0200 feat!: initial prototype diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e052b5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..dfe9090 --- /dev/null +++ b/build.gradle.kts @@ -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() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e37069 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile new file mode 100644 index 0000000..3aa10b4 --- /dev/null +++ b/docker/db.Dockerfile @@ -0,0 +1,3 @@ +# syntax=docker/dockerfile:1 +FROM postgres:latest +COPY src/main/resources/schema.sql /docker-entrypoint-initdb.d/ \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3e43a08 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c45b17c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "recipe-api" \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/RecipesApplication.java b/src/main/java/eu/bitfield/recipes/RecipesApplication.java new file mode 100644 index 0000000..4608ffb --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/RecipesApplication.java @@ -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); + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java b/src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java new file mode 100644 index 0000000..fd3b14a --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java @@ -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 errors = multiValueMap(); + for (ParameterValidationResult result : e.getParameterValidationResults()) { + Parameter param = result.getMethodParameter().getParameter(); + String errorKey = param.getName(); + List 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 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 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; + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/account/AccountEndpoint.java b/src/main/java/eu/bitfield/recipes/api/account/AccountEndpoint.java new file mode 100644 index 0000000..27cac0c --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/account/AccountEndpoint.java @@ -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 registerAccount(@RequestBody @Valid AccountIn accountIn) { + return registerOp.registerAccount(accountIn) + .onErrorMap(RegisterAccount.EmailAlreadyInUse.class, + e -> errorResponseException(e, BAD_REQUEST)) + .then(); + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/account/RegisterAccount.java b/src/main/java/eu/bitfield/recipes/api/account/RegisterAccount.java new file mode 100644 index 0000000..49d509b --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/account/RegisterAccount.java @@ -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 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);} + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java b/src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java new file mode 100644 index 0000000..4dc7bbe --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java @@ -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 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); + })); + })); + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/FindRecipes.java b/src/main/java/eu/bitfield/recipes/api/recipe/FindRecipes.java new file mode 100644 index 0000000..62a08b4 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/FindRecipes.java @@ -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> 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 + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java b/src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java new file mode 100644 index 0000000..4980eee --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java @@ -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 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);} + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/RecipeEndpoint.java b/src/main/java/eu/bitfield/recipes/api/recipe/RecipeEndpoint.java new file mode 100644 index 0000000..d8789e9 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/RecipeEndpoint.java @@ -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 addRecipe(@RequestBody @Valid RecipeViewIn recipeViewIn) { + return profile.id() + .flatMap(profileId -> addOp.addRecipe(recipeViewIn, profileId)) + .map(RecipeView::toRecipeViewOut); + } + + @GetMapping("/{recipeId}") + public Mono getRecipe(@PathVariable long recipeId) { + return getOp.getRecipe(recipeId) + .onErrorMap(GetRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND)); + } + + @PutMapping("/{recipeId}") + public Mono 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 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 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()); + } + + +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/RemoveRecipe.java b/src/main/java/eu/bitfield/recipes/api/recipe/RemoveRecipe.java new file mode 100644 index 0000000..ff19df7 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/RemoveRecipe.java @@ -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 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);} + } + +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/UpdateRecipe.java b/src/main/java/eu/bitfield/recipes/api/recipe/UpdateRecipe.java new file mode 100644 index 0000000..1df7066 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/UpdateRecipe.java @@ -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 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); + } + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java b/src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java new file mode 100644 index 0000000..d99a7b2 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java @@ -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(); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/AccountPrincipalService.java b/src/main/java/eu/bitfield/recipes/auth/AccountPrincipalService.java new file mode 100644 index 0000000..3ca4379 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/AccountPrincipalService.java @@ -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 findByUsername(String address) { + return some(address) + .mapNotNull(EmailAddressIn::of) + .map(EmailAddress::of) + .flatMap(accountServ::getAccount) + .map(AccountPrincipal::new); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/ProfileIdentity.java b/src/main/java/eu/bitfield/recipes/auth/ProfileIdentity.java new file mode 100644 index 0000000..f093ce3 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/ProfileIdentity.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.auth; + +public interface ProfileIdentity { + long profileId(); +} diff --git a/src/main/java/eu/bitfield/recipes/auth/ProfileIdentityAccess.java b/src/main/java/eu/bitfield/recipes/auth/ProfileIdentityAccess.java new file mode 100644 index 0000000..3a42f10 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/ProfileIdentityAccess.java @@ -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 id() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getPrincipal) + .cast(ProfileIdentity.class) + .map(ProfileIdentity::profileId); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/email/EmailAddress.java b/src/main/java/eu/bitfield/recipes/auth/email/EmailAddress.java new file mode 100644 index 0000000..6ac2c9e --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/email/EmailAddress.java @@ -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 { + public static Comparator 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); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/email/EmailAddressIn.java b/src/main/java/eu/bitfield/recipes/auth/email/EmailAddressIn.java new file mode 100644 index 0000000..d1c6d5d --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/email/EmailAddressIn.java @@ -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 { + 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 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); + } + +} diff --git a/src/main/java/eu/bitfield/recipes/auth/password/Password.java b/src/main/java/eu/bitfield/recipes/auth/password/Password.java new file mode 100644 index 0000000..019dde4 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/password/Password.java @@ -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 { + public static Comparator 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); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/password/PasswordIn.java b/src/main/java/eu/bitfield/recipes/auth/password/PasswordIn.java new file mode 100644 index 0000000..5bbb0e3 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/password/PasswordIn.java @@ -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 { + public static final int MIN_PASSWORD_LENGTH = 8; + public static Comparator 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); + } +} diff --git a/src/main/java/eu/bitfield/recipes/config/CacheConfiguration.java b/src/main/java/eu/bitfield/recipes/config/CacheConfiguration.java new file mode 100644 index 0000000..6b77459 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/config/CacheConfiguration.java @@ -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 caches() { + return Stream.of("account") + .map(ConcurrentMapCache::new) + .map(Cache.class::cast) + .toList(); + } +} diff --git a/src/main/java/eu/bitfield/recipes/config/DatabaseConfiguration.java b/src/main/java/eu/bitfield/recipes/config/DatabaseConfiguration.java new file mode 100644 index 0000000..0c8b838 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/config/DatabaseConfiguration.java @@ -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); + }; + } +} diff --git a/src/main/java/eu/bitfield/recipes/config/SecurityConfiguration.java b/src/main/java/eu/bitfield/recipes/config/SecurityConfiguration.java new file mode 100644 index 0000000..5a1afbc --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/config/SecurityConfiguration.java @@ -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); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/account/Account.java b/src/main/java/eu/bitfield/recipes/core/account/Account.java new file mode 100644 index 0000000..c382e8e --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/Account.java @@ -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()); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/account/AccountIn.java b/src/main/java/eu/bitfield/recipes/core/account/AccountIn.java new file mode 100644 index 0000000..07b3249 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/AccountIn.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/core/account/AccountRepository.java b/src/main/java/eu/bitfield/recipes/core/account/AccountRepository.java new file mode 100644 index 0000000..4b3a6a8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/AccountRepository.java @@ -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 { + default Mono addAccount(Account account) { + return save(account); + } + + @Query(""" + select * from account + where email = $1 + """) + Mono query_accountByEmail(String emailAddress); + + default Mono accountByEmail(EmailAddress email) { + return query_accountByEmail(email.address()); + } + + @Query(""" + select exists( + select * from account + where email = $1 + ) + """) + Mono query_isEmailUsed(String emailAddress); + + + default Mono isEmailUsed(EmailAddress email) { + return query_isEmailUsed(email.address()); + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/core/account/AccountService.java b/src/main/java/eu/bitfield/recipes/core/account/AccountService.java new file mode 100644 index 0000000..ff264a6 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/AccountService.java @@ -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 addAccount(long profileId, EmailAddress email, Password password) { + return supply(() -> Account.initial(profileId, email, password)).flatMap(repo::addAccount); + } + + @Transactional(readOnly = true) @Cacheable + public Mono getAccount(EmailAddress email) { + return repo.accountByEmail(email); + } + + @Transactional(readOnly = true) + public Mono 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);} + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/account/ToAccountIn.java b/src/main/java/eu/bitfield/recipes/core/account/ToAccountIn.java new file mode 100644 index 0000000..363efdc --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/ToAccountIn.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.account; + +public interface ToAccountIn { + AccountIn toAccountIn(); +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/Category.java b/src/main/java/eu/bitfield/recipes/core/category/Category.java new file mode 100644 index 0000000..3b95748 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/Category.java @@ -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); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/category/CategoryIn.java b/src/main/java/eu/bitfield/recipes/core/category/CategoryIn.java new file mode 100644 index 0000000..25f7a95 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/CategoryIn.java @@ -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) {} diff --git a/src/main/java/eu/bitfield/recipes/core/category/CategoryOut.java b/src/main/java/eu/bitfield/recipes/core/category/CategoryOut.java new file mode 100644 index 0000000..925215c --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/CategoryOut.java @@ -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; + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/CategoryRepository.java b/src/main/java/eu/bitfield/recipes/core/category/CategoryRepository.java new file mode 100644 index 0000000..22c7900 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/CategoryRepository.java @@ -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 { + default Mono 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 categoriesByRecipeId(long recipeId); + + @Query(""" + select id from category + where name = $1 + """) + Mono categoryIdByName(String name); +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/CategoryService.java b/src/main/java/eu/bitfield/recipes/core/category/CategoryService.java new file mode 100644 index 0000000..ac3d7c8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/CategoryService.java @@ -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> addCategories(List categoriesIn) { + return flux(categoriesIn).flatMapSequential(this::addCategoryIfAbsent).collectList(); + } + + public Mono> getCategories(long recipeId) { + return repo.categoriesByRecipeId(recipeId).collectList(); + } + + private Mono addCategoryIfAbsent(CategoryIn categoryIn) { + return repo.categoryIdByName(categoryIn.name()) + .map(id -> new Category(id, categoryIn.name())) + .switchIfEmpty(supply(() -> Category.initial(categoryIn.name())).flatMap(repo::addCategory)); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/ToCategoryIn.java b/src/main/java/eu/bitfield/recipes/core/category/ToCategoryIn.java new file mode 100644 index 0000000..17367d0 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/ToCategoryIn.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.category; + +public interface ToCategoryIn { + CategoryIn toCategoryIn(); +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/ToCategoryOut.java b/src/main/java/eu/bitfield/recipes/core/category/ToCategoryOut.java new file mode 100644 index 0000000..d5a7b1d --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/ToCategoryOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.category; + +public interface ToCategoryOut { + CategoryOut toCategoryOut(); +} diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/Ingredient.java b/src/main/java/eu/bitfield/recipes/core/ingredient/Ingredient.java new file mode 100644 index 0000000..e8bb9be --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/Ingredient.java @@ -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); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientIn.java b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientIn.java new file mode 100644 index 0000000..6a4d201 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientIn.java @@ -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) { +} diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientOut.java b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientOut.java new file mode 100644 index 0000000..3f64dd8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.ingredient; + +import com.fasterxml.jackson.annotation.JsonValue; + +public record IngredientOut(@JsonValue String name) {} diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientRepository.java b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientRepository.java new file mode 100644 index 0000000..aadff32 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientRepository.java @@ -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 addIngredientsBatch(Flux ingredients); +} + +public interface IngredientRepository extends ReactiveCrudRepository, 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 addIngredients(List ingredients) { + return saveAll(ingredients); + } + + @Query(""" + select * from ingredient + where recipe_id = $1 + """) + Flux ingredientsByRecipeId(long recipeId); + + @Modifying + @Query(""" + delete from ingredient where recipe_id = $1 + """) + Mono 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 addIngredientsBatch(Flux ingredients) { + return ingredients.collectList() + .flatMapMany(this::addIngredientsBatch); + } + + public Flux addIngredientsBatch(List ingredients) { + if (ingredients.isEmpty()) { + return Flux.empty(); + } + return client.inConnectionMany(connection -> { + Statement statement = connection.createStatement(addBatchSql); + statement.returnGeneratedValues(idColumn); + Spliterator 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 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()); + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientService.java b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientService.java new file mode 100644 index 0000000..fd22410 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientService.java @@ -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> addIngredients(long recipeId, List ingredientsIn) { + return flux(ingredientsIn) + .map(ingredientIn -> Ingredient.initial(recipeId, ingredientIn.name())) + .collectList() + .flatMapMany(repo::addIngredients) + .collectList(); + } + + public Mono> getIngredients(long recipeId) { + return repo.ingredientsByRecipeId(recipeId).collectList(); + } + + @Transactional + public Mono> updateIngredients(long recipeId, List ingredients) { + return repo.removeIngredientsFromRecipe(recipeId).then(addIngredients(recipeId, ingredients)); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientIn.java b/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientIn.java new file mode 100644 index 0000000..bedeaa5 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientIn.java @@ -0,0 +1,7 @@ +package eu.bitfield.recipes.core.ingredient; + + +public interface ToIngredientIn { + IngredientIn toIngredientIn(); +} + diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientOut.java b/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientOut.java new file mode 100644 index 0000000..3023fed --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientOut.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.core.ingredient; + +public interface ToIngredientOut { + IngredientOut toIngredientOut(); +} + diff --git a/src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java new file mode 100644 index 0000000..65c2ea8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java @@ -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); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatRepository.java b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatRepository.java new file mode 100644 index 0000000..6ec8ffe --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatRepository.java @@ -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 { + default Flux addLinks(List links) { + return saveAll(links); + } + + @Modifying + @Query(""" + delete from link_rec_cat where recipe_id = $1 + """) + Mono removeCategoriesFromRecipe(long recipeId); +} diff --git a/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatService.java b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatService.java new file mode 100644 index 0000000..862294f --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatService.java @@ -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> addLinks(long recipeId, List categories) { + return flux(categories) + .map(category -> LinkRecCat.initial(recipeId, category.id())) + .collectList() + .flatMapMany(repo::addLinks) + .collectList(); + } + + @Transactional + public Mono> updateLinks(long recipeId, List categories) { + return repo.removeCategoriesFromRecipe(recipeId).then(addLinks(recipeId, categories)); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/profile/Profile.java b/src/main/java/eu/bitfield/recipes/core/profile/Profile.java new file mode 100644 index 0000000..0f5b8df --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/profile/Profile.java @@ -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);} +} + diff --git a/src/main/java/eu/bitfield/recipes/core/profile/ProfileRepository.java b/src/main/java/eu/bitfield/recipes/core/profile/ProfileRepository.java new file mode 100644 index 0000000..665fef8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/profile/ProfileRepository.java @@ -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 { + default Mono addProfile(Profile initialProfile) { + return save(initialProfile); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/profile/ProfileService.java b/src/main/java/eu/bitfield/recipes/core/profile/ProfileService.java new file mode 100644 index 0000000..0e9605c --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/profile/ProfileService.java @@ -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 addProfile() {return supply(Profile::initial).flatMap(repo::addProfile);} +} diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java b/src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java new file mode 100644 index 0000000..c1be595 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java @@ -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); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java new file mode 100644 index 0000000..7d9c006 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java @@ -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 +) { +} diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java new file mode 100644 index 0000000..ea7335e --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java @@ -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 +) {} diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/RecipeRepository.java b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeRepository.java new file mode 100644 index 0000000..ad5a9c7 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeRepository.java @@ -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 { + default Mono addRecipe(Recipe recipe) { + return save(recipe); + } + + default Mono recipe(long recipeId) { + return findById(recipeId); + } + + + @Modifying + @Query(""" + update recipe + set name = $2, + description = $3, + changed_at = $4 + where id = $1 + """) + Mono updateRecipe(long recipeId, String name, String description, Instant changedAt); + + @Modifying + @Query(""" + delete from recipe + where id = $1 + """) + Mono 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 recipeIds(@Nullable String categoryName, @Nullable String recipeName, long limit, long offset); + + @Query(""" + select author_profile_id = $2 from recipe + where id = $1 + """) + Mono canEditRecipe(long recipeId, long profileId); +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/RecipeService.java b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeService.java new file mode 100644 index 0000000..b6c2ed7 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeService.java @@ -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 addRecipe(long authorProfileId, RecipeIn recipeIn, Instant createdAt) { + return repo.addRecipe(Recipe.initial(authorProfileId, recipeIn.name(), recipeIn.description(), createdAt)); + } + + public Mono getRecipe(long recipeId) { + return repo.recipe(recipeId).as(checkEmpty(recipeId)); + } + + public Flux findRecipeIds(@Nullable String categoryName, @Nullable String recipeName, Pagination pagination) { + return repo.recipeIds(categoryName, recipeName, pagination.limit(), pagination.offset()); + } + + public Mono 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 removeRecipe(long recipeId, long profileId) { + return checkEdit(recipeId, profileId, () -> new RemoveRecipeForbidden(recipeId)) + .then(repo.removeRecipe(recipeId)); + } + + private UnaryOperator> checkEmpty(long recipeId) { + return (Mono item) -> item.as(errIfEmpty(() -> new RecipeNotFound(recipeId))); + } + + private Mono checkEdit(long recipeId, long profileId, Supplier 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);} + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeIn.java b/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeIn.java new file mode 100644 index 0000000..ab7129d --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeIn.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.core.recipe; + +public interface ToRecipeIn { + RecipeIn toRecipeIn(); +} + diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeOut.java b/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeOut.java new file mode 100644 index 0000000..73079de --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.recipe; + +public interface ToRecipeOut { + RecipeOut toRecipeOut(); +} diff --git a/src/main/java/eu/bitfield/recipes/core/step/Step.java b/src/main/java/eu/bitfield/recipes/core/step/Step.java new file mode 100644 index 0000000..a60c8c7 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/Step.java @@ -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); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/step/StepIn.java b/src/main/java/eu/bitfield/recipes/core/step/StepIn.java new file mode 100644 index 0000000..8f18329 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/StepIn.java @@ -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) { +} diff --git a/src/main/java/eu/bitfield/recipes/core/step/StepOut.java b/src/main/java/eu/bitfield/recipes/core/step/StepOut.java new file mode 100644 index 0000000..6797b2f --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/StepOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.step; + +import com.fasterxml.jackson.annotation.JsonValue; + +public record StepOut(@JsonValue String name) {} diff --git a/src/main/java/eu/bitfield/recipes/core/step/StepRepository.java b/src/main/java/eu/bitfield/recipes/core/step/StepRepository.java new file mode 100644 index 0000000..955fe95 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/StepRepository.java @@ -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 { + // 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 addSteps(List steps) { + return saveAll(steps); + } + + @Query(""" + select * from step + where recipe_id = $1 + order by id; + """) + Flux stepsByRecipeId(long recipeId); + + @Modifying + @Query(""" + delete from step where recipe_id = $1 + """) + Mono removeStepsFromRecipe(long recipeId); +} diff --git a/src/main/java/eu/bitfield/recipes/core/step/StepService.java b/src/main/java/eu/bitfield/recipes/core/step/StepService.java new file mode 100644 index 0000000..6ed50fe --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/StepService.java @@ -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> addSteps(long recipeId, List stepsIn) { + return flux(stepsIn).map(stepIn -> Step.initial(recipeId, stepIn.name())) + .collectList() + .flatMapMany(repo::addSteps) + .collectList(); + } + + public Mono> getSteps(long recipeId) { + return repo.stepsByRecipeId(recipeId).collectList(); + } + + public Mono> updateSteps(long recipeId, List stepsIn) { + return repo.removeStepsFromRecipe(recipeId).then(addSteps(recipeId, stepsIn)); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/step/ToStepIn.java b/src/main/java/eu/bitfield/recipes/core/step/ToStepIn.java new file mode 100644 index 0000000..842593f --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/ToStepIn.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.core.step; + +public interface ToStepIn { + StepIn toStepIn(); +} + diff --git a/src/main/java/eu/bitfield/recipes/core/step/ToStepOut.java b/src/main/java/eu/bitfield/recipes/core/step/ToStepOut.java new file mode 100644 index 0000000..fb0edeb --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/ToStepOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.step; + +public interface ToStepOut { + StepOut toStepOut(); +} diff --git a/src/main/java/eu/bitfield/recipes/log/EventMatcherEvaluator.java b/src/main/java/eu/bitfield/recipes/log/EventMatcherEvaluator.java new file mode 100644 index 0000000..7942e4f --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/log/EventMatcherEvaluator.java @@ -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 { + @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(); + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/package-info.java b/src/main/java/eu/bitfield/recipes/package-info.java new file mode 100644 index 0000000..d696bea --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package eu.bitfield.recipes; + +import org.springframework.lang.NonNullApi; \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/util/AsyncUtils.java b/src/main/java/eu/bitfield/recipes/util/AsyncUtils.java new file mode 100644 index 0000000..2a3f2fa --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/AsyncUtils.java @@ -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 Function supplyFn(Supplier supplier) { + return (T t) -> supplier.get(); + } + + public static UnaryOperator> errIfEmpty(Supplier err) { + return (Mono mono) -> mono.switchIfEmpty(err(err)); + } + + public static UnaryOperator> errIf(Predicate shouldErr, Function err) { + return (Mono mono) -> mono.flatMap(item -> shouldErr.test(item) ? err(err.apply(item)) : some(item)); + } + + public static UnaryOperator> errIfNot(Predicate isOk, Function err) { + return (Mono mono) -> mono.flatMap(item -> isOk.test(item) ? some(item) : err(err.apply(item))); + } + + public static Mono some(T some) {return Mono.just(some);} + + @SafeVarargs + public static Flux many(T... many) {return Flux.just(many);} + + public static Mono none() {return Mono.empty();} + + public static Mono err(Supplier error) {return Mono.error(error);} + + public static Mono err(Throwable error) {return Mono.error(error);} + + public static Function, Mono> errIfTrue(Supplier err) { + return (Mono mono) -> mono.flatMap(ok -> ok ? err(err.get()) : some(false)); + } + + public static Function, Mono> errIfFalse(Supplier errorSupplier) { + return (Mono mono) -> mono.flatMap(ok -> ok ? some(true) : err(errorSupplier.get())); + } + + public static Mono supply(Supplier supplier) {return Mono.fromSupplier(supplier);} + + public static Flux flux(Iterable iterable) {return Flux.fromIterable(iterable);} + + public static Flux flux(Stream stream) {return Flux.fromStream(stream);} + + public static UnaryOperator> chain(Function> visit) { + return (Mono mono) -> mono.flatMap(t1 -> visit.apply(t1).thenReturn(t1)); + } + + public static Mono defer(MonoSupplier supplier) {return Mono.defer(supplier);} + + public static Flux defer(FluxSupplier supplier) {return Flux.defer(supplier);} + + public static UnaryOperator> takeUntilChanged(Function keySelector) { + return (Flux items) -> items.windowUntilChanged(keySelector).take(1).flatMap(identity()); + } + + @FunctionalInterface + public interface MonoSupplier extends Supplier> {} + + @FunctionalInterface + public interface FluxSupplier extends Supplier> {} +} diff --git a/src/main/java/eu/bitfield/recipes/util/Chronology.java b/src/main/java/eu/bitfield/recipes/util/Chronology.java new file mode 100644 index 0000000..b93a628 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/Chronology.java @@ -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);} +} diff --git a/src/main/java/eu/bitfield/recipes/util/CollectionUtils.java b/src/main/java/eu/bitfield/recipes/util/CollectionUtils.java new file mode 100644 index 0000000..bb7546d --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/CollectionUtils.java @@ -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 MultiValueMap multiValueMap() { + return MultiValueMap.fromMultiValue(new HashMap<>()); + } + + public static MultiValueMap multiValueMap(Map> map) { + return MultiValueMap.fromMultiValue(map); + } + + public static Collector> toHashSet() { + return toCollection(HashSet::new); + } + + public static Collector> toArrayList() { + return toCollection(ArrayList::new); + } + + public static Function, R> entryFn(BiFunction fn) { + return entry -> fn.apply(entry.getKey(), entry.getValue()); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/util/Entity.java b/src/main/java/eu/bitfield/recipes/util/Entity.java new file mode 100644 index 0000000..e390218 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/Entity.java @@ -0,0 +1,8 @@ +package eu.bitfield.recipes.util; + +import org.springframework.data.annotation.Immutable; + +@Immutable +public interface Entity extends Id { + long id(); +} diff --git a/src/main/java/eu/bitfield/recipes/util/ErrorUtils.java b/src/main/java/eu/bitfield/recipes/util/ErrorUtils.java new file mode 100644 index 0000000..ed91976 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/ErrorUtils.java @@ -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(); + } +} diff --git a/src/main/java/eu/bitfield/recipes/util/Id.java b/src/main/java/eu/bitfield/recipes/util/Id.java new file mode 100644 index 0000000..1f4a5b0 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/Id.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.util; + +public interface Id { + long id(); +} + diff --git a/src/main/java/eu/bitfield/recipes/util/Pagination.java b/src/main/java/eu/bitfield/recipes/util/Pagination.java new file mode 100644 index 0000000..926ff12 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/Pagination.java @@ -0,0 +1,3 @@ +package eu.bitfield.recipes.util; + +public record Pagination(long limit, long offset) {} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/RecipeView.java b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeView.java new file mode 100644 index 0000000..6abd41c --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeView.java @@ -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 categories, + List links, + List ingredients, + List steps +) implements ToRecipeViewOut { + + public static RecipeViewOut createRecipeViewOut( + Recipe recipe, + List categories, + List ingredients, + List steps) + { + RecipeOut recipeOut = recipe.toRecipeOut(); + List categoriesOut = categories.stream().map(Category::toCategoryOut).toList(); + List ingredientsOut = ingredients.stream().map(Ingredient::toIngredientOut).toList(); + List stepsOut = steps.stream().map(Step::toStepOut).toList(); + return new RecipeViewOut(recipeOut, categoriesOut, ingredientsOut, stepsOut); + } + + public RecipeViewOut toRecipeViewOut() { + return createRecipeViewOut(recipe, categories, ingredients, steps); + } +} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewIn.java b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewIn.java new file mode 100644 index 0000000..3cff760 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewIn.java @@ -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; + } +} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewOut.java b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewOut.java new file mode 100644 index 0000000..4261748 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewOut.java @@ -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 categories, + List ingredients, + List steps +) implements ToRecipeOut { + public RecipeOut toRecipeOut() { + return recipe; + } +} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewIn.java b/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewIn.java new file mode 100644 index 0000000..070faba --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewIn.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.view.recipe; + +public interface ToRecipeViewIn { + RecipeViewIn toRecipeViewIn(); +} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewOut.java b/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewOut.java new file mode 100644 index 0000000..a84ac06 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.view.recipe; + +public interface ToRecipeViewOut { + RecipeViewOut toRecipeViewOut(); +} diff --git a/src/main/java/eu/bitfield/recipes/view/registration/RegistrationView.java b/src/main/java/eu/bitfield/recipes/view/registration/RegistrationView.java new file mode 100644 index 0000000..f7d3615 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/registration/RegistrationView.java @@ -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 +) {} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..9553b68 --- /dev/null +++ b/src/main/resources/application.yaml @@ -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 diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..0591894 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,26 @@ + + + + + + + ${CONSOLE_LOG_THRESHOLD} + + + + ^io\.r2dbc\.postgresql\.QUERY$ + Executing query: SHOW TRANSACTION ISOLATION LEVEL + + DENY + NEUTRAL + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..ac51170 --- /dev/null +++ b/src/main/resources/schema.sql @@ -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 +); diff --git a/src/test/java/eu/bitfield/recipes/api/APITest.java b/src/test/java/eu/bitfield/recipes/api/APITest.java new file mode 100644 index 0000000..d7248eb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/APITest.java @@ -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 allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List 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 viewsOut = allViewsOut.stream() + .filter((RecipeViewOut viewOut) -> viewOut.categories().stream().anyMatch(hasCategoryName(categoryName))) + .toList(); + List 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); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/account/AccountEndpointTest.java b/src/test/java/eu/bitfield/recipes/api/account/AccountEndpointTest.java new file mode 100644 index 0000000..419984d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/account/AccountEndpointTest.java @@ -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(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/account/RegisterAccountTest.java b/src/test/java/eu/bitfield/recipes/api/account/RegisterAccountTest.java new file mode 100644 index 0000000..d690951 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/account/RegisterAccountTest.java @@ -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); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/AddRecipeTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/AddRecipeTest.java new file mode 100644 index 0000000..17bc8e5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/AddRecipeTest.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/FindRecipesTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/FindRecipesTest.java new file mode 100644 index 0000000..230bbcf --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/FindRecipesTest.java @@ -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 allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List groups = allGroups.stream() + .filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName))) + .sorted(orderByRecipeChangedAtDesc()) + .toList(); + List savedViewsOut = groups.stream().map(toRecipeViewOut).toList(); + List 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 actualViewsOut) -> { + assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut); + }); + } + + @Test + void findRecipes_recipeNamePart_recipeViewsOut() { + String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra + List allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List groups = allGroups.stream() + .filter(containsRecipeName(recipeName)) + .sorted(orderByRecipeChangedAtDesc()) + .toList(); + List savedViewsOut = groups.stream().map(toRecipeViewOut).toList(); + List 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 actualViewsOut) -> { + assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut); + }); + } +} \ No newline at end of file diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/GetRecipeTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/GetRecipeTest.java new file mode 100644 index 0000000..38fb4e4 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/GetRecipeTest.java @@ -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(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/RecipeEndpointTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/RecipeEndpointTest.java new file mode 100644 index 0000000..655f0ec --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/RecipeEndpointTest.java @@ -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 allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List groups = allGroups.stream() + .filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName))) + .sorted(orderByRecipeChangedAtDesc()) + .toList(); + List viewsOut = groups.stream().map(toRecipeViewOut).toList(); + + when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut)); + + List 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 allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List groups = allGroups.stream() + .filter(containsRecipeName(recipeName)) + .sorted(orderByRecipeChangedAtDesc()) + .toList(); + List viewsOut = groups.stream().map(RecipeViewGroup::toRecipeViewOut).toList(); + + when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut)); + + List 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(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/RemoveRecipeTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/RemoveRecipeTest.java new file mode 100644 index 0000000..9571840 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/RemoveRecipeTest.java @@ -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(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/UpdateRecipeTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/UpdateRecipeTest.java new file mode 100644 index 0000000..8ada779 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/UpdateRecipeTest.java @@ -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 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(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/auth/AccountPrincipalServiceTest.java b/src/test/java/eu/bitfield/recipes/auth/AccountPrincipalServiceTest.java new file mode 100644 index 0000000..6b5aa3c --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/auth/AccountPrincipalServiceTest.java @@ -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(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/account/AccountRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/account/AccountRepositoryTest.java new file mode 100644 index 0000000..728afa4 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/account/AccountRepositoryTest.java @@ -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 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(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/account/AccountServiceTest.java b/src/test/java/eu/bitfield/recipes/core/account/AccountServiceTest.java new file mode 100644 index 0000000..bff2405 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/account/AccountServiceTest.java @@ -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(); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/core/category/CategoryRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/category/CategoryRepositoryTest.java new file mode 100644 index 0000000..354082a --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/category/CategoryRepositoryTest.java @@ -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 recipes = recipeSlotsWithLinkCounts(2, 1).map(to::blank).toList(); + RecipeSlot recipe = recipes.getFirst(); + List categories = linkedCategorySlots(recipe).map(to::blank).toList(); + List 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 actual) -> { + List saved = categories.stream().map(to::saved).toList(); + assertThat(actual).containsExactlyInAnyOrderElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void categoryIdByName_existentCategoryName_categoryId() { + List 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(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/category/CategoryServiceTest.java b/src/test/java/eu/bitfield/recipes/core/category/CategoryServiceTest.java new file mode 100644 index 0000000..6058dce --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/category/CategoryServiceTest.java @@ -0,0 +1,77 @@ +package eu.bitfield.recipes.core.category; + +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.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +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 CategoryServiceTest implements LinkRecCatQueries { + CategoryRepository repo = mock(CategoryRepository.class); + CategoryService serv = new CategoryService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + + @Test + void addCategories_categoriesIn_savedCategories() { + List categories = categorySlots().map(to::blank).toList(); + save(slots(categories)); + List saved = categories.stream().map(to::saved).toList(); + List categoriesIn = categories.stream().map(toCategoryIn).toList(); + int presentCount = categories.size() / 2; + + categories.stream().limit(presentCount).forEach((CategorySlot category) -> { + when(repo.categoryIdByName(category.name())).thenReturn(some(category.id())); + }); + categories.stream().skip(presentCount).forEach((CategorySlot category) -> { + when(repo.categoryIdByName(category.name())).thenReturn(none()); + when(repo.addCategory(category.initial())).thenReturn(some(category.saved())); + }); + + serv.addCategories(categoriesIn) + .as(StepVerifier::create) + .assertNext((List allActual) -> { + assertThat(allActual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void getCategories_existentRecipeId_linkedCategoriesOut() { + RecipeSlot recipe = recipeSlot().blank(); + List links = linkRecCatSlots(recipe).map(to::blank).toList(); + List categories = linkedCategorySlots(recipe).map(to::blank).toList(); + save(slots(links)); + List saved = categories.stream().map(to::saved).toList(); + + when(repo.categoriesByRecipeId(recipe.id())).thenReturn(flux(saved)); + + serv.getCategories(recipe.id()) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientRepositoryTest.java new file mode 100644 index 0000000..42fbe75 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientRepositoryTest.java @@ -0,0 +1,182 @@ +package eu.bitfield.recipes.core.ingredient; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.profile.ProfileRepository; +import eu.bitfield.recipes.core.recipe.RecipeRepository; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientQueries; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +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.Id; +import eu.bitfield.recipes.util.Transaction; +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.InvalidEntity.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static java.util.Comparator.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +class IngredientRepositoryTest implements IngredientQueries { + final IngredientRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final @Delegate RecipeLayer recipeLayer; + final @Delegate IngredientLayer ingredientLayer; + final Transaction transaction; + + @Autowired + public IngredientRepositoryTest( + ProfileRepository profileRepo, + RecipeRepository recipeRepo, + IngredientRepository ingredientRepo, + TransactionalOperator transactionalOp) + { + this.repo = ingredientRepo; + this.rootStorage = new AsyncRootStorage(); + var layers = new AsyncLayerFactory(rootStorage); + this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo)); + this.recipeLayer = layers.recipeLayer(asyncPersistence(recipeRepo)); + this.ingredientLayer = layers.ingredientLayer(asyncPersistence(ingredientRepo)); + this.transaction = new Transaction(transactionalOp); + } + + @Test + void addIngredients_validInitialIngredients_savedIngredients() { + List ingredients = ingredientSlots(recipeSlot().blank()).limit(2).map(to::blank).toList(); + @NoArgsConstructor @Setter @Accessors(fluent = true) + class Check { + Ingredient initial, actual, saved; + } + + var checks = flux(ingredients) + .map(to::initial) + .collectList() + .zipWhen((List allInitial) -> repo.addIngredients(allInitial).collectList()) + .flatMapMany(function((List allInitial, List allActual) -> { + return flux(allInitial).zipWithIterable(allActual); + })) + .map(function((Ingredient initial, Ingredient actual) -> new Check().initial(initial).actual(actual))) + .concatMap((Check c) -> repo.findById(c.actual.id()).map(c::saved)) + .collectList(); + + save(slots(ingredients)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List allChecks) -> { + assertThat(allChecks).hasSameSizeAs(ingredients); + assertThat(allChecks).allSatisfy((Check c) -> { + assertThat(c.actual.id()).isNotZero(); + assertThat(c.actual).usingRecursiveComparison() + .ignoringFields(Ingredient.Fields.id) + .isEqualTo(c.initial); + assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved); + }); + }) + .verifyComplete(); + } + + @Test + void addIngredients_invalidInitialIngredientsNonExistentRecipe_errorForeignKeyConstraintViolation() { + IngredientSlot ingredient = ingredientSlot().blank(); + RecipeSlot recipe = ingredient.recipe(); + + var checks = many(ingredient) + .map(to::initial) + .collectList() + .flatMapMany(repo::addIngredients) + .then(); + + invalidate(recipe); + init(slot(ingredient)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void ingredientsOutByRecipeId_existentRecipeId_ingredientsOut() { + List groups = recipeIngredientGroupsWithIngredientCounts(2, 1); + List allIngredients = groups.stream() + .flatMap(RecipeIngredientGroup::ingredients) + .map(to::blank) + .toList(); + RecipeSlot recipe = groups.getFirst().recipe().blank(); + List ingredients = groups.getFirst().ingredients().map(to::blank).toList(); + + var checks = defer(() -> repo.ingredientsByRecipeId(recipe.id())).collectList(); + + save(slots(allIngredients)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List actual) -> { + List saved = ingredients.stream() + .sorted(comparing(Id::id)) + .map(to::saved) + .toList(); + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void ingredientsOutByRecipeId_nonExistentRecipeId_none() { + var checks = repo.ingredientsByRecipeId(freeId); + + checks.as(transaction::rollbackVerify) + .verifyComplete(); + } + + @Test + void removeIngredientsFromRecipe_existentRecipeId_removedCount() { + List groups = recipeIngredientGroupsWithIngredientCounts(2, 1); + List allIngredients = groups.stream() + .flatMap(RecipeIngredientGroup::ingredients) + .map(to::blank) + .toList(); + RecipeSlot recipe = groups.getFirst().recipe().blank(); + + var checks = some(recipe).map(toId).flatMap(repo::removeIngredientsFromRecipe) + .zipWhen((Long actualRemovedCount) -> repo.findAll().collectList()); + + save(slots(allIngredients)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Long actualRemovedCount, List allRemaining) -> { + assertThat(actualRemovedCount).isEqualTo(2); + assertThat(allRemaining).allSatisfy(remaining -> { + assertThat(remaining.recipeId()).isNotEqualTo(recipe.id()); + }); + })) + .verifyComplete(); + } + + @Test + void removeIngredientsFromRecipe_nonExistentRecipeId_zero() { + var checks = repo.removeIngredientsFromRecipe(freeId); + + checks.as(transaction::rollbackVerify) + .expectNext(0L) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientServiceTest.java b/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientServiceTest.java new file mode 100644 index 0000000..49151de --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientServiceTest.java @@ -0,0 +1,92 @@ +package eu.bitfield.recipes.core.ingredient; + +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientQueries; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +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.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +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 IngredientServiceTest implements IngredientQueries { + IngredientRepository repo = mock(IngredientRepository.class); + IngredientService serv = new IngredientService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate IngredientLayer ingredientLayer = layers.ingredientLayer(); + + @Test + void addIngredients_ingredientsInOfExistentRecipe_savedIngredients() { + RecipeIngredientGroup group = recipeIngredientGroupWithIngredientCount(5); + RecipeSlot recipe = group.recipe().blank(); + List ingredients = group.ingredients().map(to::blank).toList(); + save(slot(recipe).add(ingredients)); + List initial = ingredients.stream().map(to::initial).toList(); + List saved = ingredients.stream().map(to::saved).toList(); + List savedIn = ingredients.stream().map(toIngredientIn).toList(); + + when(repo.addIngredients(initial)).thenReturn(flux(saved)); + + serv.addIngredients(recipe.id(), savedIn) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } + + @Test + void getIngredients_existentRecipeId_ingredients() { + RecipeIngredientGroup group = recipeIngredientGroupWithIngredientCount(5); + RecipeSlot recipe = group.recipe().blank(); + List ingredients = group.ingredients().map(to::blank).toList(); + save(slot(recipe).add(ingredients)); + List saved = ingredients.stream().map(to::saved).toList(); + + when(repo.ingredientsByRecipeId(recipe.id())).thenReturn(flux(saved)); + + serv.getIngredients(recipe.id()) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void updateIngredients_newIngredients_updatedIngredients() { + RecipeIngredientGroup group = recipeIngredientGroupWithIngredientCount(5); + RecipeSlot recipe = group.recipe().blank(); + List ingredients = group.ingredients().map(to::blank).toList(); + save(slot(recipe).add(ingredients)); + List initial = ingredients.stream().map(to::initial).toList(); + List saved = ingredients.stream().map(to::saved).toList(); + List savedIn = ingredients.stream().map(toIngredientIn).toList(); + long oldIngredientCount = saved.size() - 1; + + when(repo.removeIngredientsFromRecipe(recipe.id())).thenReturn(some(oldIngredientCount)); + when(repo.addIngredients(initial)).thenReturn(flux(saved)); + + serv.updateIngredients(recipe.id(), savedIn) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatRepositoryTest.java new file mode 100644 index 0000000..b159d66 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatRepositoryTest.java @@ -0,0 +1,167 @@ +package eu.bitfield.recipes.core.link; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.category.CategoryRepository; +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.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.test.data.Initial; +import eu.bitfield.recipes.util.Transaction; +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 +public class LinkRecCatRepositoryTest implements LinkRecCatQueries { + final LinkRecCatRepository 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 LinkRecCatRepositoryTest( + ProfileRepository profileRepo, + RecipeRepository recipeRepo, + CategoryRepository categoryRepo, + LinkRecCatRepository linkRepo, + TransactionalOperator transactionalOp) + { + this.repo = linkRepo; + 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 addLinks_validInitialLinks_savedLinks() { + List links = linkRecCatSlots().limit(2).map(to::blank).toList(); + @NoArgsConstructor @Setter @Accessors(fluent = true) + class Check { + LinkRecCat initial, actual, saved; + } + + var checks = flux(links) + .map(to::initial) + .collectList() + .zipWhen((List allInitial) -> repo.addLinks(allInitial).collectList()) + .flatMapMany(function((List allInitial, List allActual) -> { + return flux(allInitial).zipWithIterable(allActual); + })) + .map(function((LinkRecCat initial, LinkRecCat actual) -> new Check().initial(initial).actual(actual))) + .concatMap((Check c) -> repo.findById(c.actual.id()).map(c::saved)) + .collectList(); + + init(slots(links)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List allChecks) -> { + assertThat(allChecks).hasSameSizeAs(links); + assertThat(allChecks).allSatisfy((Check c) -> { + assertThat(c.actual.id()).isNotZero(); + assertThat(c.actual).usingRecursiveComparison() + .ignoringFields(LinkRecCat.Fields.id) + .isEqualTo(c.initial); + assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved); + }); + }) + .verifyComplete(); + } + + @Test + void addLinks_invalidInitialLinksNonExistentCategory_errorForeignKeyConstraintViolation() { + LinkRecCatSlot link = linkRecCatSlots().findFirst().orElseThrow().blank(); + + var checks = many(link) + .map(Initial::initial) + .collectList() + .flatMapMany(repo::addLinks) + .then(); + + invalidate(link.category()); + init(slot(link)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void addLinks_invalidInitialLinksNonExistentRecipe_errorForeignKeyConstraintViolation() { + LinkRecCatSlot link = linkRecCatSlots().findFirst().orElseThrow().blank(); + + var checks = many(link) + .map(Initial::initial) + .collectList() + .flatMapMany(repo::addLinks) + .then(); + + invalidate(link.recipe()); + init(slot(link)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void addLinks_validInitialLinksDuplicate_errorUniqueConstraintViolation() { + LinkRecCatSlot link = linkRecCatSlots().findFirst().orElseThrow().blank(); + + var checks = many(link) + .map(Initial::initial) + .collectList() + .flatMapMany(repo::addLinks) + .then(); + + save(slot(link)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void removeCategories_existentRecipe_removedCategoryCount() { + List recipes = recipeSlotsWithLinkCounts(2, 1).map(to::blank).toList(); + RecipeSlot recipeForRemoval = recipes.getFirst(); + List links = recipes.stream().flatMap(this::linkRecCatSlots).map(to::blank).toList(); + + var checks = some(recipeForRemoval).map(toId).flatMap(repo::removeCategoriesFromRecipe); + + save(slots(links)) + .then(checks) + .as(transaction::rollbackVerify) + .expectNext(2L) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatServiceTest.java b/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatServiceTest.java new file mode 100644 index 0000000..b8ae829 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatServiceTest.java @@ -0,0 +1,76 @@ +package eu.bitfield.recipes.core.link; + +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +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.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +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 LinkRecCatServiceTest implements LinkRecCatQueries { + LinkRecCatRepository repo = mock(LinkRecCatRepository.class); + LinkRecCatService serv = new LinkRecCatService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + + @Test + void addLinks_existentRecipeCategories_savedLinkRecCats() { + RecipeSlot recipe = recipeSlotWithLinkCount(1).blank(); + List links = linkRecCatSlots(recipe).map(to::blank).toList(); + save(slots(links)); + List categories = links.stream().map(LinkRecCatSlot::category).map(to::saved).toList(); + List initial = links.stream().map(to::initial).toList(); + List saved = links.stream().map(to::saved).toList(); + + when(repo.addLinks(initial)).thenReturn(flux(saved)); + + serv.addLinks(recipe.id(), categories) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void updateLinks_newExistentRecipeCategories_updatedLinkRecCats() { + RecipeSlot recipe = recipeSlotWithLinkCount(1).blank(); + List links = linkRecCatSlots(recipe).map(to::blank).toList(); + save(slots(links)); + List categories = links.stream().map(LinkRecCatSlot::category).map(to::saved).toList(); + List initial = links.stream().map(to::initial).toList(); + List saved = links.stream().map(to::saved).toList(); + long categoryCount = categories.size(); + long oldCategoryCount = categoryCount - 1; + + when(repo.removeCategoriesFromRecipe(recipe.id())).thenReturn(some(oldCategoryCount)); + when(repo.addLinks(initial)).thenReturn(flux(saved)); + + serv.updateLinks(recipe.id(), categories) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/profile/ProfileRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/profile/ProfileRepositoryTest.java new file mode 100644 index 0000000..31153eb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/profile/ProfileRepositoryTest.java @@ -0,0 +1,58 @@ +package eu.bitfield.recipes.core.profile; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileQueries; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +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.transaction.reactive.TransactionalOperator; + +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +class ProfileRepositoryTest implements ProfileQueries { + final ProfileRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final Transaction transaction; + + @Autowired + public ProfileRepositoryTest(ProfileRepository profileRepo, TransactionalOperator transactionalOperator) { + this.repo = profileRepo; + this.rootStorage = new AsyncRootStorage(); + var layers = new AsyncLayerFactory(rootStorage); + this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo)); + this.transaction = new Transaction(transactionalOperator); + } + + @Test + void addProfile_initialProfile_savedProfile() { + ProfileSlot profile = profileSlot().blank(); + + var checks = defer(() -> repo.addProfile(profile.initial())) + .zipWhen(actual -> repo.findById(actual.id())); + + save(slot(profile)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Profile actual, Profile saved) -> { + Profile initial = profile.initial(); + assertThat(actual.id()).isNotZero(); + assertThat(actual).usingRecursiveComparison().ignoringFields(Profile.Fields.id).isEqualTo(initial); + assertThat(actual).usingRecursiveComparison().isEqualTo(saved); + })) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/profile/ProfileServiceTest.java b/src/test/java/eu/bitfield/recipes/core/profile/ProfileServiceTest.java new file mode 100644 index 0000000..8d5360f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/profile/ProfileServiceTest.java @@ -0,0 +1,39 @@ +package eu.bitfield.recipes.core.profile; + +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileQueries; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +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.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class ProfileServiceTest implements ProfileQueries { + ProfileRepository repo = mock(ProfileRepository.class); + ProfileService serv = new ProfileService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + + @Test + void addProfile_initialProfile_savedProfile() { + ProfileSlot profile = profileSlot().blank(); + save(slot(profile)); + + when(repo.addProfile(profile.initial())).thenReturn(some(profile.saved())); + + serv.addProfile() + .as(StepVerifier::create) + .assertNext((Profile added) -> { + assertThat(added).isEqualTo(profile.saved()); + }) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/recipe/RecipeRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/recipe/RecipeRepositoryTest.java new file mode 100644 index 0000000..b8a00e5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/recipe/RecipeRepositoryTest.java @@ -0,0 +1,305 @@ +package eu.bitfield.recipes.core.recipe; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.category.CategoryRepository; +import eu.bitfield.recipes.core.link.LinkRecCatRepository; +import eu.bitfield.recipes.core.profile.ProfileRepository; +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.profile.ProfileSlot; +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.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.data.util.Pair; +import org.springframework.transaction.reactive.TransactionalOperator; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.api.recipe.RecipeEndpoint.*; +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.AsyncUtils.defer; +import static eu.bitfield.recipes.util.TestUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.core.publisher.Flux.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +public class RecipeRepositoryTest implements LinkRecCatQueries { + static final long limit = MAX_LIMIT; + static final long offset = 0; + + final RecipeRepository 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 RecipeRepositoryTest( + ProfileRepository profileRepo, + RecipeRepository recipeRepo, + CategoryRepository categoryRepo, + LinkRecCatRepository linkRepo, + TransactionalOperator transactionalOp) + { + this.repo = recipeRepo; + 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 addRecipe_validInitialRecipe_savedRecipe() { + RecipeSlot recipe = recipeSlot().blank(); + + var checks = defer(() -> repo.addRecipe(recipe.initial())) + .zipWhen(actual -> repo.findById(actual.id())); + + init(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Recipe actual, Recipe saved) -> { + Recipe initial = recipe.initial(); + assertThat(actual.id()).isNotZero(); + assertThat(actual).usingRecursiveComparison().ignoringFields(Recipe.Fields.id).isEqualTo(initial); + assertThat(actual).usingRecursiveComparison().isEqualTo(saved); + })) + .verifyComplete(); + } + + @Test + void addRecipe_invalidInitialRecipeNonExistentProfileId_errorForeignKeyConstraintViolation() { + RecipeSlot recipe = recipeSlot().blank(); + + var checks = defer(() -> repo.addRecipe(recipe.initial())); + + invalidate(recipe.author()); + init(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void recipeOut_existentRecipeId_recipeOut() { + List recipes = recipeSlots().limit(2).map(to::blank).toList(); + RecipeSlot recipe = recipes.getFirst(); + + var checks = defer(() -> repo.recipe(recipe.id())); + + save(slots(recipes)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((Recipe actual) -> { + assertThat(actual).usingRecursiveComparison().isEqualTo(recipe.saved()); + }) + .verifyComplete(); + } + + @Test + void recipeOut_nonExistentRecipeId_none() { + var checks = repo.recipe(freeId); + + checks.as(transaction::rollbackVerify) + .verifyComplete(); + } + + @Test + void updateRecipe_existentRecipeUpdate_true() { + RecipeSlot recipe = recipeSlot().blank(); + @NoArgsConstructor @AllArgsConstructor @Setter @Accessors(fluent = true) + class Check { + Recipe update; + Boolean rowsChanged; + Recipe after; + } + + var checks = defer(() -> { + Instant changedAt = realTime().now(); + Recipe saved = recipe.saved(); + String name = saved.name() + "...new1"; + String description = saved.description() + "...new2"; + Recipe update = new Recipe(saved.id(), saved.authorProfileId(), name, description, changedAt); + return supply(() -> new Check().update(update)) + .zipWhen((Check c) -> repo.updateRecipe(saved.id(), name, description, changedAt), Check::rowsChanged); + }) + .zipWhen((Check c) -> repo.findById(recipe.id()), Check::after); + + save(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((Check c) -> { + assertThat(c.rowsChanged).isTrue(); + assertThat(c.update).usingRecursiveComparison().isEqualTo(c.after); + }) + .verifyComplete(); + } + + @Test + void updateRecipe_nonExistentRecipeUpdate_false() { + Instant changedAt = realTime().now(); + long id = freeId; + String name = "name"; + String description = "description"; + + var checks = repo.updateRecipe(id, name, description, changedAt) + .zipWhen((Boolean rowsChanged) -> repo.findById(id).singleOptional()); + + + checks.as(transaction::rollbackVerify) + .assertNext(consumer((Boolean rowsChanged, Optional after) -> { + assertThat(rowsChanged).isFalse(); + assertThat(after).isNotPresent(); + })) + .verifyComplete(); + } + + @Test + void removeRecipe_existentRecipeId_true() { + RecipeSlot recipe = recipeSlot().blank(); + + var checks = defer(() -> repo.removeRecipe(recipe.id()) + .zipWhen((Boolean rowsChanged) -> repo.findById(recipe.id()).singleOptional())); + + save(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Boolean rowsChanged, Optional after) -> { + assertThat(rowsChanged).isTrue(); + assertThat(after).isNotPresent(); + })) + .verifyComplete(); + } + + @Test + void removeRecipe_nonExistentRecipeId_false() { + var checks = repo.removeRecipe(freeId); + + checks.as(transaction::rollbackVerify) + .expectNext(false) + .verifyComplete(); + } + + @Test + void recipeIds_someCategoryName$noRecipeName_recipeIds() { + List allCategories = categorySlotsWithLinkCounts(2, 1, 0).map(to::blank).toList(); + Map> recipesByCategory = allCategories.stream() + .map((CategorySlot category) -> Pair.of(category, linkedRecipeSlots(category).map(to::blank).toList())) + .collect(Pair.toMap()); + List links = allCategories.stream().flatMap(this::linkRecCatSlots).map(to::blank).toList(); + String recipeName = null; + + var checks = flux(allCategories) + .map(CategorySlot::name) + .concatMap((String categoryName) -> many(categoryName, categoryName.toUpperCase())) + .concatMap((String categoryName) -> { + List categories = allCategories.stream().filter(hasCategoryName(categoryName)).toList(); + var savedRecipeIds = flux(categories) + .concatMap((CategorySlot category) -> flux(recipesByCategory.get(category))) + .distinct() + .sort(orderByRecipeChangedAtDesc()) + .map(toId) + .collectList(); + return repo.recipeIds(categoryName, recipeName, limit, offset).collectList() + .zipWhen((List actualRecipeIds) -> savedRecipeIds); + }) + .collectList(); + + save(slots(links).add(allCategories)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(thatAllSatisfy(consumer((List actualRecipeIds, List savedRecipeIds) -> { + assertThat(actualRecipeIds).containsExactlyElementsOf(savedRecipeIds); + }))) + .verifyComplete(); + } + + @Test + void recipeIds_noCategoryName$someRecipeName_recipeIds() { + List allRecipes = Stream.of(recipes.muhammara, recipes.tomatoSalad, recipes.hummus) + .map(this::recipeSlot) + .map(to::blank) + .toList(); + String categoryName = null; + + var checks = concat(flux(allRecipes).map(RecipeSlot::name), many("ma", "pears")) + .concatMap(recipeName -> many(recipeName, recipeName.toUpperCase())) + .concatMap(recipeName -> { + var savedRecipeIds = flux(allRecipes) + .filter(containsRecipeName(recipeName)) + .sort(orderByRecipeChangedAtDesc()) + .map(toId) + .collectList(); + return repo.recipeIds(categoryName, recipeName, limit, offset).collectList() + .zipWhen((List actualRecipeIds) -> savedRecipeIds); + }) + .collectList(); + save(slots(allRecipes)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(thatAllSatisfy(consumer((List actualRecipeIds, List savedRecipeIds) -> { + assertThat(actualRecipeIds).containsExactlyElementsOf(savedRecipeIds); + }))) + .verifyComplete(); + } + + @Test + void canEditRecipe_recipeAuthorId_true() { + RecipeSlot recipe = recipeSlot().blank(); + + var checks = defer(() -> repo.canEditRecipe(recipe.id(), recipe.author().id())); + + save(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .expectNext(true) + .verifyComplete(); + } + + @Test + void canEditRecipe_recipeNotAuthorId_false() { + ProfileSlot author = profileSlot(profiles.ada).blank(); + ProfileSlot notAuthor = profileSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author).blank(); + + var checks = defer(() -> repo.canEditRecipe(recipe.id(), notAuthor.id())); + + save(slots(author, notAuthor).add(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .expectNext(false) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/recipe/RecipeServiceTest.java b/src/test/java/eu/bitfield/recipes/core/recipe/RecipeServiceTest.java new file mode 100644 index 0000000..aee55d3 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/recipe/RecipeServiceTest.java @@ -0,0 +1,220 @@ +package eu.bitfield.recipes.core.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound; +import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden; +import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden; +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.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.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.util.Pagination; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Stream; + +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 eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class RecipeServiceTest implements LinkRecCatQueries { + RecipeRepository repo = mock(RecipeRepository.class); + RecipeService serv = new RecipeService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + + @Test + void addRecipe_recipeInAndIdOfExistentAuthorProfile_savedRecipe() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + RecipeIn recipeIn = recipe.toRecipeIn(); + Instant createdAt = recipe.saved().changedAt(); + + when(repo.addRecipe(recipe.initial())).thenReturn(some(recipe.saved())); + + serv.addRecipe(recipe.author().id(), recipeIn, createdAt) + .as(StepVerifier::create) + .expectNext(recipe.saved()) + .verifyComplete(); + } + + @Test + void getRecipe_nonExistentRecipeId_errorRecipeNotFound() { + long recipeId = freeId; + + when(repo.recipe(recipeId)).thenReturn(none()); + + serv.getRecipe(recipeId) + .as(StepVerifier::create) + .verifyError(RecipeNotFound.class); + } + + @Test + void getRecipe_existentRecipeId_recipeOut() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + + when(repo.recipe(recipe.id())).thenReturn(some(recipe.saved())); + + serv.getRecipe(recipe.id()) + .as(StepVerifier::create) + .expectNext(recipe.saved()) + .verifyComplete(); + } + + @Test + void findRecipeIds_categoryName_recipeIds() { + CategorySlot category = categorySlotWithLinkCount(2).blank(); + List recipes = linkedRecipeSlots(category).map(to::blank).toList(); + save(slot(category).add(recipes)); + List savedIds = recipes.stream().sorted(orderByRecipeChangedAtDesc()).map(toId).toList(); + long limit = savedIds.size(); + long offset = 0; + var pagination = new Pagination(limit, offset); + + when(repo.recipeIds(category.name(), null, limit, offset)).thenReturn(flux(savedIds)); + + serv.findRecipeIds(category.name(), null, pagination) + .collectList() + .as(StepVerifier::create) + .assertNext((List actualIds) -> { + assertThat(actualIds).containsExactlyElementsOf(savedIds); + }).verifyComplete(); + } + + + @Test + void findRecipeIds_recipeNamePart_recipeIds() { + String recipeName = "minestro"; + List recipes = recipeSlots().map(to::blank).toList(); + save(slots(recipes)); + Stream matchingRecipes = recipes.stream().filter(containsRecipeName(recipeName)); + List savedIds = matchingRecipes.sorted(orderByRecipeChangedAtDesc()).map(toId).toList(); + long limit = savedIds.size(); + long offset = 0; + var pagination = new Pagination(limit, offset); + + when(repo.recipeIds(null, recipeName, limit, offset)).thenReturn(flux(savedIds)); + + serv.findRecipeIds(null, recipeName, pagination) + .collectList() + .as(StepVerifier::create) + .assertNext((List actualIds) -> { + assertThat(actualIds).containsExactlyElementsOf(savedIds); + }) + .verifyComplete(); + } + + @Test + void updateRecipe_nonExistentRecipeId_errorRecipeNotFound() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + RecipeIn recipeIn = recipe.toRecipeIn(); + long recipeId = freeId; + long profileId = recipe.author().id(); + Instant changedAt = realTime().now(); + + when(repo.canEditRecipe(recipeId, profileId)).thenReturn(none()); + when(repo.updateRecipe(anyLong(), any(), any(), any())).thenReturn(noSubscriptionMono()); + + serv.updateRecipe(recipeId, profileId, recipeIn, changedAt) + .as(StepVerifier::create) + .verifyError(RecipeNotFound.class); + } + + @Test + void updateRecipe_notAuthorProfileId_errorUpdateRecipeForbidden() { + ProfileSlot author = profileSlot(profiles.ada).blank(); + ProfileSlot notAuthor = profileSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author).blank(); + save(slots(author, notAuthor).add(recipe)); + RecipeIn recipeIn = recipe.toRecipeIn(); + Instant changedAt = realTime().now(); + + when(repo.canEditRecipe(recipe.id(), notAuthor.id())).thenReturn(some(false)); + when(repo.updateRecipe(anyLong(), any(), any(), any())).thenReturn(noSubscriptionMono()); + + serv.updateRecipe(recipe.id(), notAuthor.id(), recipeIn, changedAt) + .as(StepVerifier::create) + .verifyError(UpdateRecipeForbidden.class); + } + + @Test + void updateRecipe_anyRecipeIn_updatedRecipe() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + RecipeIn recipeIn = recipe.toRecipeIn(); + Instant changedAt = recipe.saved().changedAt(); + + when(repo.canEditRecipe(recipe.id(), recipe.author().id())).thenReturn(some(true)); + when(repo.updateRecipe(recipe.id(), recipeIn.name(), recipeIn.description(), changedAt)).thenReturn(some(true)); + + serv.updateRecipe(recipe.id(), recipe.author().id(), recipeIn, changedAt) + .as(StepVerifier::create) + .expectNext(recipe.saved()) + .verifyComplete(); + } + + @Test + void removeRecipe_nonExistentRecipeId_errorRecipeNotFound() { + long recipeId = freeId; + ProfileSlot profile = profileSlot().blank(); + save(slot(profile)); + + when(repo.canEditRecipe(recipeId, profile.id())).thenReturn(none()); + when(repo.removeRecipe(anyLong())).thenReturn(noSubscriptionMono()); + + serv.removeRecipe(recipeId, profile.id()) + .as(StepVerifier::create) + .verifyError(RecipeNotFound.class); + } + + @Test + void removeRecipe_notAuthorProfileId_errorRemoveRecipeForbidden() { + 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(repo.canEditRecipe(recipe.id(), notAuthor.id())).thenReturn(some(false)); + when(repo.removeRecipe(anyLong())).thenReturn(noSubscriptionMono()); + + serv.removeRecipe(recipe.id(), notAuthor.id()) + .as(StepVerifier::create) + .verifyError(RemoveRecipeForbidden.class); + } + + @Test + void removeRecipe_existentRecipeId_true() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + + when(repo.canEditRecipe(recipe.id(), recipe.author().id())).thenReturn(some(true)); + when(repo.removeRecipe(anyLong())).thenReturn(some(true)); + + serv.removeRecipe(recipe.id(), recipe.author().id()) + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/step/StepRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/step/StepRepositoryTest.java new file mode 100644 index 0000000..af4242a --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/step/StepRepositoryTest.java @@ -0,0 +1,183 @@ +package eu.bitfield.recipes.core.step; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.profile.ProfileRepository; +import eu.bitfield.recipes.core.recipe.RecipeRepository; +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.core.step.StepQueries; +import eu.bitfield.recipes.test.core.step.StepSlot; +import eu.bitfield.recipes.test.data.AsyncLayerFactory; +import eu.bitfield.recipes.test.data.AsyncRootStorage; +import eu.bitfield.recipes.util.Id; +import eu.bitfield.recipes.util.Transaction; +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.InvalidEntity.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static java.util.Comparator.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +class StepRepositoryTest implements StepQueries { + final StepRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final @Delegate RecipeLayer recipeLayer; + final @Delegate StepLayer stepLayer; + final Transaction transaction; + + @Autowired + public StepRepositoryTest( + ProfileRepository profileRepo, + RecipeRepository recipeRepo, + StepRepository stepRepo, + TransactionalOperator transactionalOp) + { + this.repo = stepRepo; + this.rootStorage = new AsyncRootStorage(); + var layers = new AsyncLayerFactory(rootStorage); + this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo)); + this.recipeLayer = layers.recipeLayer(asyncPersistence(recipeRepo)); + this.stepLayer = layers.stepLayer(asyncPersistence(stepRepo)); + this.transaction = new Transaction(transactionalOp); + } + + @Test + void addSteps_validInitialSteps_savedSteps() { + List steps = stepSlots(recipeSlot().blank()).limit(2).map(to::blank).toList(); + @NoArgsConstructor @Setter @Accessors(fluent = true) + class Check { + Step initial, actual, saved; + } + + var checks = flux(steps) + .map(to::initial) + .collectList() + .zipWhen((List allInitial) -> repo.addSteps(allInitial).collectList()) + .flatMapMany(function((List allInitial, List allActual) -> { + return flux(allInitial).zipWithIterable(allActual); + })) + .map(function((Step initial, Step actual) -> new Check().initial(initial).actual(actual))) + .concatMap((Check c) -> repo.findById(c.actual.id()).map(c::saved)) + .collectList(); + + save(slots(steps)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List allChecks) -> { + assertThat(allChecks).hasSameSizeAs(steps); + assertThat(allChecks).allSatisfy((Check c) -> { + assertThat(c.actual.id()).isNotZero(); + assertThat(c.actual).usingRecursiveComparison() + .ignoringFields(Step.Fields.id) + .isEqualTo(c.initial); + assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved); + }); + }) + .verifyComplete(); + } + + @Test + void addSteps_invalidInitialStepsNonExistentRecipe_errorForeignKeyConstraintViolation() { + StepSlot step = stepSlot().blank(); + RecipeSlot recipe = step.recipe(); + + var checks = many(step) + .map(to::initial) + .collectList() + .flatMapMany(repo::addSteps) + .then(); + + invalidate(recipe); + init(slot(step)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void stepsOutByRecipeId_existentRecipeId_stepsOut() { + List groups = recipeStepGroupsWithStepCounts(2, 1); + List allSteps = groups.stream() + .flatMap(RecipeStepGroup::steps) + .map(to::blank) + .toList(); + RecipeSlot recipe = groups.getFirst().recipe().blank(); + List steps = groups.getFirst().steps().map(to::blank).toList(); + + var checks = defer(() -> repo.stepsByRecipeId(recipe.id())).collectList(); + + save(slots(allSteps)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List actual) -> { + List saved = steps.stream() + .sorted(comparing(Id::id)) + .map(to::saved) + .toList(); + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void stepsOutByRecipeId_nonExistentRecipeId_none() { + var checks = repo.stepsByRecipeId(freeId); + + checks.as(transaction::rollbackVerify) + .verifyComplete(); + } + + @Test + void removeStepsFromRecipe_existentRecipeId_removedCount() { + List groups = recipeStepGroupsWithStepCounts(2, 1); + List allSteps = groups.stream() + .flatMap(RecipeStepGroup::steps) + .map(to::blank) + .toList(); + RecipeSlot recipe = groups.getFirst().recipe().blank(); + + var checks = some(recipe).map(toId).flatMap(repo::removeStepsFromRecipe) + .zipWhen((Long actualRemovedCount) -> repo.findAll().collectList()); + + save(slots(allSteps)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Long actualRemovedCount, List allRemaining) -> { + assertThat(actualRemovedCount).isEqualTo(2); + assertThat(allRemaining).allSatisfy(remaining -> { + assertThat(remaining.recipeId()).isNotEqualTo(recipe.id()); + }); + })) + .verifyComplete(); + } + + @Test + void removeStepsFromRecipe_nonExistentRecipeId_zero() { + var checks = repo.removeStepsFromRecipe(freeId); + + checks.as(transaction::rollbackVerify) + .expectNext(0L) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/step/StepServiceTest.java b/src/test/java/eu/bitfield/recipes/core/step/StepServiceTest.java new file mode 100644 index 0000000..88b0c53 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/step/StepServiceTest.java @@ -0,0 +1,92 @@ +package eu.bitfield.recipes.core.step; + +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.core.step.StepQueries; +import eu.bitfield.recipes.test.core.step.StepSlot; +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 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 StepServiceTest implements StepQueries { + StepRepository repo = mock(StepRepository.class); + StepService serv = new StepService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate StepLayer stepLayer = layers.stepLayer(); + + @Test + void addSteps_stepsInOfExistentRecipe_savedSteps() { + RecipeStepGroup group = recipeStepGroupWithStepCount(5); + RecipeSlot recipe = group.recipe().blank(); + List steps = group.steps().map(to::blank).toList(); + save(slot(recipe).add(steps)); + List initial = steps.stream().map(to::initial).toList(); + List saved = steps.stream().map(to::saved).toList(); + List savedIn = steps.stream().map(toStepIn).toList(); + + when(repo.addSteps(initial)).thenReturn(flux(saved)); + + serv.addSteps(recipe.id(), savedIn) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } + + @Test + void getSteps_existentRecipeId_stepsOut() { + RecipeStepGroup group = recipeStepGroupWithStepCount(5); + RecipeSlot recipe = group.recipe().blank(); + List steps = group.steps().map(to::blank).toList(); + save(slot(recipe).add(steps)); + List saved = steps.stream().map(to::saved).toList(); + + when(repo.stepsByRecipeId(recipe.id())).thenReturn(flux(saved)); + + serv.getSteps(recipe.id()) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void updateSteps_newSteps_updatedSteps() { + RecipeStepGroup group = recipeStepGroupWithStepCount(5); + RecipeSlot recipe = group.recipe().blank(); + List steps = group.steps().map(to::blank).toList(); + save(slot(recipe).add(steps)); + List initial = steps.stream().map(to::initial).toList(); + List saved = steps.stream().map(to::saved).toList(); + List savedIn = steps.stream().map(toStepIn).toList(); + long oldStepCount = saved.size() - 1; + + when(repo.removeStepsFromRecipe(recipe.id())).thenReturn(some(oldStepCount)); + when(repo.addSteps(initial)).thenReturn(flux(saved)); + + serv.updateSteps(recipe.id(), savedIn) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/AsyncPersistence.java b/src/test/java/eu/bitfield/recipes/test/AsyncPersistence.java new file mode 100644 index 0000000..fb59dd1 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/AsyncPersistence.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.test; + +import eu.bitfield.recipes.util.Entity; +import lombok.RequiredArgsConstructor; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Mono; + +public interface AsyncPersistence { + static AsyncPersistence asyncPersistence(ReactiveCrudRepository repo) { + return new ReactiveCrudRepositoryPersistence<>(repo); + } + + Mono save(E initial); +} + +@RequiredArgsConstructor +class ReactiveCrudRepositoryPersistence implements AsyncPersistence { + private final ReactiveCrudRepository repo; + + public Mono save(E initial) {return repo.save(initial);} +} diff --git a/src/test/java/eu/bitfield/recipes/test/InvalidEntity.java b/src/test/java/eu/bitfield/recipes/test/InvalidEntity.java new file mode 100644 index 0000000..34ae873 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/InvalidEntity.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test; + +public class InvalidEntity { + public static long freeId = Long.MAX_VALUE; +} diff --git a/src/test/java/eu/bitfield/recipes/test/Persistence.java b/src/test/java/eu/bitfield/recipes/test/Persistence.java new file mode 100644 index 0000000..2dfa608 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/Persistence.java @@ -0,0 +1,31 @@ +package eu.bitfield.recipes.test; + +import eu.bitfield.recipes.util.Entity; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import reactor.core.publisher.Flux; + +public interface Persistence { + static Persistence persistence(PersistWithId persistWithId) { + return new InMemoryIdSequencePersistence<>(persistWithId); + } + + E save(E initial); + + default Flux saveAll(Flux initial) { + return initial.map(this::save); + } + + @FunctionalInterface + interface PersistWithId { + E persist(E initial, long id); + } +} + +@RequiredArgsConstructor +class InMemoryIdSequencePersistence implements Persistence { + private final @Delegate PersistWithId persistWithId; + private long maxId = 0; + + public E save(E initial) {return persist(initial, ++maxId);} +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/APICall.java b/src/test/java/eu/bitfield/recipes/test/api/APICall.java new file mode 100644 index 0000000..4529c0d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/APICall.java @@ -0,0 +1,227 @@ +package eu.bitfield.recipes.test.api; + +import eu.bitfield.recipes.test.auth.ToAuth; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClient.RequestBodySpec; +import org.springframework.test.web.reactive.server.WebTestClient.RequestBodyUriSpec; +import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec; +import org.springframework.web.util.UriBuilder; + +import java.net.URI; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import static eu.bitfield.recipes.test.api.APICall.RequestBody.*; +import static eu.bitfield.recipes.test.api.APICall.RequestHeaders.*; +import static eu.bitfield.recipes.test.api.APICall.RequestMethod.*; +import static eu.bitfield.recipes.test.api.APICall.RequestURI.*; +import static eu.bitfield.recipes.test.api.APICall.ResponseBody.*; +import static eu.bitfield.recipes.test.api.APICall.ResponseStatus.*; +import static eu.bitfield.recipes.util.TestUtils.*; +import static java.util.Objects.*; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.http.HttpMethod.*; +import static org.springframework.http.HttpStatus.*; + +@NoArgsConstructor @Slf4j +public abstract class APICall { + Consumer authHeaders = null; + + protected static EntityExchangeResult log(EntityExchangeResult result) { + log.debug("{}", result); + return result; + } + + protected Consumer authHeaders() { + return assertNotNull(authHeaders); + } + + protected RequestMethod request(WebTestClient client) { + return requestMethod(client); + } + + @AllArgsConstructor + protected static class RequestMethod { + private WebTestClient client; + + public static RequestMethod requestMethod(WebTestClient client) { + return new RequestMethod(client); + } + + RequestURI method(HttpMethod method) { + return requestUri(client.method(method)); + } + + public RequestURI get() { + return method(GET); + } + + public RequestURI head() { + return method(HEAD); + } + + public RequestURI post() { + return method(POST); + } + + public RequestURI put() { + return method(PUT); + } + + public RequestURI patch() { + return method(PATCH); + } + + public RequestURI delete() { + return method(DELETE); + } + + public RequestURI options() { + return method(OPTIONS); + } + + public RequestURI trace() { + return method(TRACE); + } + } + + @AllArgsConstructor + protected static class RequestURI { + private RequestBodyUriSpec spec; + + public static RequestURI requestUri(RequestBodyUriSpec spec) { + return new RequestURI(spec); + } + + public RequestHeaders uri(String uri, Object... objects) { + return requestHeaders(spec.uri(uri, objects)); + } + + public RequestHeaders uri(Function uriBuilder) { + return requestHeaders(spec.uri(uriBuilder)); + } + + } + + @AllArgsConstructor + protected static class RequestHeaders { + private RequestBodySpec spec; + + public static RequestHeaders requestHeaders(RequestBodySpec spec) { + return new RequestHeaders(spec); + } + + public RequestBody headers(Consumer headersConsumer) { + return requestBody(spec.headers(headersConsumer)); + } + + public RequestBody noHeaders() { + return requestBody(spec); + } + } + + @AllArgsConstructor + protected static class RequestBody { + private RequestBodySpec spec; + + public static RequestBody requestBody(RequestBodySpec spec) { + return new RequestBody(spec); + } + + public ResponseStatus jsonBody(Object value) { + return responseStatus(spec.contentType(MediaType.APPLICATION_JSON).bodyValue(value).exchange()); + } + + public ResponseStatus noBody() { + return responseStatus(spec.exchange()); + } + + } + + @AllArgsConstructor + protected static class ResponseStatus { + private ResponseSpec spec; + + public static ResponseStatus responseStatus(ResponseSpec spec) { + return new ResponseStatus(spec); + } + + public ResponseBody status(HttpStatus status) { + return responseBody(spec.expectStatus().isEqualTo(status)); + } + + public ResponseBody ok() { + return status(OK); + } + + public ResponseBody unauthorized() { + return status(UNAUTHORIZED); + } + + public ResponseBody badRequest() { + return status(BAD_REQUEST); + } + + public ResponseBody forbidden() { + return status(FORBIDDEN); + } + + public ResponseBody notFound() { + return status(NOT_FOUND); + } + + } + + @AllArgsConstructor + protected static class ResponseBody { + private ResponseSpec spec; + + public static ResponseBody responseBody(ResponseSpec spec) { + return new ResponseBody(spec); + } + + public T body(Class bodyClass) { + EntityExchangeResult result = spec.expectBody(bodyClass).returnResult(); + return requireNonNull(log(result).getResponseBody()); + } + + public List listBody(Class bodyClass) { + EntityExchangeResult> result = spec.expectBodyList(bodyClass).returnResult(); + return requireNonNull(log(result).getResponseBody()); + } + + public void emptyBody() { + EntityExchangeResult result = spec.expectBody().isEmpty(); + log(result); + } + } + + @RequiredArgsConstructor @Setter @Accessors(fluent = true) + protected class AuthCall { + private final C call; + + public C as(ToAuth auth) { + assertThat(authHeaders).isNull(); + authHeaders = auth.toAuth().authHeaders(); + return call; + } + + public C anonymous() { + assertThat(authHeaders).isNull(); + authHeaders = (HttpHeaders headers) -> {}; + return call; + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/APICalls.java b/src/test/java/eu/bitfield/recipes/test/api/APICalls.java new file mode 100644 index 0000000..06f539f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/APICalls.java @@ -0,0 +1,35 @@ +package eu.bitfield.recipes.test.api; + +import eu.bitfield.recipes.core.account.ToAccountIn; +import eu.bitfield.recipes.test.api.account.RegisterAccountCall; +import eu.bitfield.recipes.test.api.recipe.*; +import eu.bitfield.recipes.view.recipe.ToRecipeViewIn; +import org.springframework.test.web.reactive.server.WebTestClient; + +public interface APICalls { + WebTestClient client(); + + default RegisterAccountCall registerAccountCall(ToAccountIn accountIn) { + return new RegisterAccountCall(client()).accountIn(accountIn); + } + + default AddRecipeCall addRecipeCall(ToRecipeViewIn viewIn) { + return new AddRecipeCall(client()).viewIn(viewIn); + } + + default GetRecipeCall getRecipeCall(long recipeId) { + return new GetRecipeCall(client()).recipeId(recipeId); + } + + default RemoveRecipeCall removeRecipeCall(long recipeId) { + return new RemoveRecipeCall(client()).recipeId(recipeId); + } + + default UpdateRecipeCall updateRecipeCall(long recipeId, ToRecipeViewIn viewIn) { + return new UpdateRecipeCall(client()).recipeId(recipeId).viewIn(viewIn); + } + + default FindRecipesCall findRecipesCall() { + return new FindRecipesCall(client()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/account/RegisterAccountCall.java b/src/test/java/eu/bitfield/recipes/test/api/account/RegisterAccountCall.java new file mode 100644 index 0000000..01eddbd --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/account/RegisterAccountCall.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.test.api.account; + +import eu.bitfield.recipes.core.account.ToAccountIn; +import eu.bitfield.recipes.test.api.APICall; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class RegisterAccountCall extends APICall { + private final WebTestClient client; + private ToAccountIn accountIn = null; + + private ResponseStatus response() { + return request(client).post().uri("/api/account/register") + .noHeaders() + .jsonBody(accountIn.toAccountIn()); + } + + public void ok() { + response().ok().emptyBody(); + } + + public void badRequest() { + response().badRequest().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/AddRecipeCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/AddRecipeCall.java new file mode 100644 index 0000000..397415b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/AddRecipeCall.java @@ -0,0 +1,36 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import eu.bitfield.recipes.view.recipe.ToRecipeViewIn; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class AddRecipeCall extends APICall { + private final WebTestClient client; + private final @Delegate AuthCall auth = new AuthCall<>(this); + private ToRecipeViewIn viewIn = null; + + private ResponseStatus response() { + return request(client).post().uri("/api/recipe/new") + .headers(authHeaders()) + .jsonBody(viewIn.toRecipeViewIn()); + } + + public RecipeViewOut ok() { + return response().ok().body(RecipeViewOut.class); + } + + public ProblemDetail badRequest() { + return response().badRequest().body(ProblemDetail.class); + } + + public void unauthorized() { + response().unauthorized().emptyBody(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/FindRecipesCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/FindRecipesCall.java new file mode 100644 index 0000000..8080ecc --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/FindRecipesCall.java @@ -0,0 +1,49 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import eu.bitfield.recipes.util.Pagination; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.util.UriBuilder; + +import java.util.List; + +import static java.util.Objects.*; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class FindRecipesCall extends APICall { + private final WebTestClient client; + private String category = null; + private String recipe = null; + private Long limit = null; + private Long offset = null; + + public FindRecipesCall pagination(Pagination pagination) { + return limit(pagination.limit()).offset(pagination.offset()); + } + + private ResponseStatus response() { + return request(client).get().uri((UriBuilder uri) -> { + uri.path("/api/recipe/search") + .queryParam("limit", requireNonNull(limit)) + .queryParam("offset", requireNonNull(offset)); + if (recipe != null) uri.queryParam("recipe", recipe); + if (category != null) uri.queryParam("category", category); + return uri.build(); + }) + .noHeaders() + .noBody(); + } + + public List ok() { + return response().ok().listBody(RecipeViewOut.class); + } + + public ProblemDetail badRequest() { + return response().badRequest().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/GetRecipeCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/GetRecipeCall.java new file mode 100644 index 0000000..e96cf55 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/GetRecipeCall.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class GetRecipeCall extends APICall { + private final WebTestClient client; + private Long recipeId = null; + + private ResponseStatus response() { + return request(client).get().uri("/api/recipe/{id}", recipeId) + .noHeaders() + .noBody(); + } + + public RecipeViewOut ok() { + return response().ok().body(RecipeViewOut.class); + } + + public ProblemDetail notFound() { + return response().notFound().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/RemoveRecipeCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/RemoveRecipeCall.java new file mode 100644 index 0000000..d5bf518 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/RemoveRecipeCall.java @@ -0,0 +1,38 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class RemoveRecipeCall extends APICall { + private final WebTestClient client; + private final @Delegate AuthCall auth = new AuthCall<>(this); + private Long recipeId = null; + + private ResponseStatus response() { + return request(client).delete().uri("/api/recipe/{id}", recipeId) + .headers(authHeaders()) + .noBody(); + } + + public void ok() { + response().ok().emptyBody(); + } + + public void unauthorized() { + response().unauthorized().emptyBody(); + } + + public ProblemDetail forbidden() { + return response().forbidden().body(ProblemDetail.class); + } + + public ProblemDetail notFound() { + return response().notFound().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/UpdateRecipeCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/UpdateRecipeCall.java new file mode 100644 index 0000000..6c6b27b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/UpdateRecipeCall.java @@ -0,0 +1,45 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import eu.bitfield.recipes.view.recipe.ToRecipeViewIn; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class UpdateRecipeCall extends APICall { + private final WebTestClient client; + private final @Delegate AuthCall auth = new AuthCall<>(this); + private Long recipeId = null; + private @Setter ToRecipeViewIn viewIn = null; + + private ResponseStatus response() { + return request(client).put().uri("/api/recipe/{id}", recipeId) + .headers(authHeaders()) + .jsonBody(viewIn.toRecipeViewIn()); + } + + public RecipeViewOut ok() { + return response().ok().body(RecipeViewOut.class); + } + + public ProblemDetail notFound() { + return response().notFound().body(ProblemDetail.class); + } + + public void unauthorized() { + response().unauthorized().emptyBody(); + } + + public ProblemDetail forbidden() { + return response().forbidden().body(ProblemDetail.class); + } + + public ProblemDetail badRequest() { + return response().badRequest().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/auth/Auth.java b/src/test/java/eu/bitfield/recipes/test/auth/Auth.java new file mode 100644 index 0000000..1161760 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/auth/Auth.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.test.auth; + +import org.springframework.http.HttpHeaders; + +import java.util.function.Consumer; + +public sealed interface Auth extends ToAuth permits BasicAuth { + Consumer authHeaders(); + + default Auth toAuth() { + return this; + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/auth/BasicAuth.java b/src/test/java/eu/bitfield/recipes/test/auth/BasicAuth.java new file mode 100644 index 0000000..0c537ae --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/auth/BasicAuth.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.test.auth; + +import org.springframework.http.HttpHeaders; + +import java.util.function.Consumer; + +public record BasicAuth(String username, String password) implements Auth { + public Consumer authHeaders() { + return (HttpHeaders headers) -> headers.setBasicAuth(username, password); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/auth/ToAuth.java b/src/test/java/eu/bitfield/recipes/test/auth/ToAuth.java new file mode 100644 index 0000000..2ed726f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/auth/ToAuth.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.test.auth; + +public interface ToAuth { + Auth toAuth(); +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountActions.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountActions.java new file mode 100644 index 0000000..549b714 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountActions.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.Context; + +import java.util.Collection; + +public interface AccountActions { + Context accountContext(); + + AccountTemplate accountTemplate(ProfileTag profileTag); + + Collection accountTemplates(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountLayer.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountLayer.java new file mode 100644 index 0000000..015053c --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountLayer.java @@ -0,0 +1,41 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.password.Password; +import eu.bitfield.recipes.auth.password.PasswordIn; +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.Context; +import eu.bitfield.recipes.test.data.SlotInitializer; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Collection; + +import static lombok.AccessLevel.*; + +@RequiredArgsConstructor @Accessors(fluent = true) +public class AccountLayer implements AccountActions, SlotInitializer { + private final AccountTemplates templates = new AccountTemplates(); + private final @Getter(PROTECTED) PasswordEncoder encoder = new BCryptPasswordEncoder(4); + private final Context context = new Context<>(); + + public Context accountContext() { + return context; + } + + public AccountTemplate accountTemplate(ProfileTag profileTag) { + return templates.get(profileTag); + } + + public Collection accountTemplates() { + return templates.all(); + } + + + public void init(AccountSlot slot) { + Password password = Password.of((PasswordIn passwordIn) -> encoder.encode(passwordIn.raw()), slot.passwordIn()); + slot.init(password); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountMask.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountMask.java new file mode 100644 index 0000000..bd227e1 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountMask.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +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.ToAccountIn; +import eu.bitfield.recipes.test.core.profile.ProfileTag; + + +public interface AccountMask extends ToAccountIn { + ProfileTag profileTag(); + + EmailAddressIn emailIn(); + + default EmailAddress email() { + return EmailAddress.of(emailIn()); + } + + PasswordIn passwordIn(); + + default AccountIn toAccountIn() { + return new AccountIn(emailIn(), passwordIn()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountQueries.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountQueries.java new file mode 100644 index 0000000..a44c80d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountQueries.java @@ -0,0 +1,73 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.test.core.profile.ProfileQueries; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.*; + +public interface AccountQueries extends ProfileQueries, AccountActions { + EmailAddress unusedEmail = AccountQueries.emailAddress("unused@example.com"); + + static EmailAddress emailAddress(String address) { + EmailAddressIn emailIn = EmailAddressIn.of(address); + assertThat(emailIn).isNotNull(); + return EmailAddress.of(emailIn); + } + + default AccountSlotBuilder accountSlot() { + return new AccountSlotBuilder(this); + } + + default AccountSlotBuilder accountSlot(AccountTemplate template) { + return accountSlot().template(template); + } + + default AccountSlotBuilder accountSlot(ProfileTag profileTag) { + return accountSlot(accountTemplate(profileTag)); + } + + default Stream accountSlots() { + return accountTemplates().stream().map(this::accountSlot); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class AccountSlotBuilder implements SlotBuilder { + private final @Delegate AccountQueries queries; + private @Getter @Setter @Nullable AccountTemplate template = null; + private @Getter @Setter @Nullable ProfileSlot profile = null; + + public void resolve() { + if (template != null) { + if (profile != null && profile.tag() != template.profileTag()) { + throw new IllegalArgumentException("Profile tag mismatch"); + } + + if (profile == null) { + profile = profileSlot(template.profileTag()).blank(); + } + return; + } + profile = Optional.ofNullable(profile).orElseGet(() -> profileSlot().blank()); + template = Optional.ofNullable(template).orElseGet(() -> accountTemplate(profile.tag())); + } + + public AccountSlot blank() { + resolve(); + return accountContext().add(new AccountSlot(template, profile)); + } + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountSlot.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountSlot.java new file mode 100644 index 0000000..224bd13 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountSlot.java @@ -0,0 +1,60 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.password.Password; +import eu.bitfield.recipes.core.account.Account; +import eu.bitfield.recipes.test.auth.Auth; +import eu.bitfield.recipes.test.auth.BasicAuth; +import eu.bitfield.recipes.test.auth.ToAuth; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class AccountSlot implements Comparable, EntitySlot, AccountMask, ToAuth { + public static EntityToken accountToken = new EntityToken<>(3, AccountSlot.class); + public static Comparator order = comparing(AccountSlot::template).thenComparing(AccountSlot::profile); + private final @Delegate AccountTemplate template; + private final ProfileSlot profile; + private transient Password password; + private transient Account initial; + private transient Account saved; + + public int compareTo(@Nullable AccountSlot other) { + return order.compare(this, other); + } + + public void init(Password password) { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + this.password = password; + this.initial = Account.initial(profile.id(), email(), password); + } + + public AnyEntityToken entityToken() { + return accountToken; + } + + public void save(Account saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(profile); + } + + public Auth toAuth() { + return new BasicAuth(emailIn().address(), passwordIn().raw()); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplate.java new file mode 100644 index 0000000..a2ad923 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplate.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.auth.password.PasswordIn; +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.Template; +import lombok.With; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +@With +public record AccountTemplate(ProfileTag profileTag, EmailAddressIn emailIn, PasswordIn passwordIn) + implements Comparable, AccountMask, Template { + public static Comparator order = + comparing(AccountTemplate::profileTag) + .thenComparing(AccountTemplate::emailIn) + .thenComparing(AccountTemplate::passwordIn); + + public int compareTo(@Nullable AccountTemplate other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplates.java new file mode 100644 index 0000000..81575a0 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplates.java @@ -0,0 +1,24 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.auth.password.PasswordIn; +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.TemplateContainer; + +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; + + +public class AccountTemplates extends TemplateContainer { + public AccountTemplates() { + add(profiles.ada, "ada"); + add(profiles.bea, "bea"); + add(profiles.rio, "rio"); + } + + private void add(ProfileTag profileTag, String name) { + EmailAddressIn emailIn = EmailAddressIn.ofUnchecked(name + "@example.com"); + PasswordIn passwordIn = PasswordIn.ofUnchecked("MyName" + name.toUpperCase() + "IsNotAPassword"); + + add(profileTag, new AccountTemplate(profileTag, emailIn, passwordIn)); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryActions.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryActions.java new file mode 100644 index 0000000..1a2b8cf --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryActions.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Context; + +import java.util.Collection; + +public interface CategoryActions { + Context categoryContext(); + + Collection categoryTemplates(); + + CategoryTemplate categoryTemplate(CategoryTag tag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryLayer.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryLayer.java new file mode 100644 index 0000000..31ca87f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryLayer.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; + +import java.util.Collection; + +@RequiredArgsConstructor +public class CategoryLayer implements CategoryActions { + private final CategoryTemplates templates = new CategoryTemplates(); + private final Context context = new Context<>(); + + public Context categoryContext() { + return context; + } + + public Collection categoryTemplates() { + return templates.all(); + } + + public CategoryTemplate categoryTemplate(CategoryTag tag) { + return templates.get(tag); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryMask.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryMask.java new file mode 100644 index 0000000..56207a4 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryMask.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.core.category.CategoryIn; +import eu.bitfield.recipes.core.category.ToCategoryIn; + +public interface CategoryMask extends ToCategoryIn { + CategoryTag tag(); + + String name(); + + default CategoryIn toCategoryIn() { + return new CategoryIn(name()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryQueries.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryQueries.java new file mode 100644 index 0000000..327c5b2 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryQueries.java @@ -0,0 +1,56 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.core.category.ToCategoryOut; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.core.category.CategoryTags.*; + +public interface CategoryQueries extends CategoryActions { + default CategorySlotBuilder categorySlot() { + return new CategorySlotBuilder(this); + } + + default CategorySlotBuilder categorySlot(CategoryTemplate template) { + return categorySlot().template(template); + } + + default CategorySlotBuilder categorySlot(CategoryTag tag) { + return categorySlot(categoryTemplate(tag)); + } + + default Stream categorySlots() { + return categoryTemplates().stream().map(this::categorySlot); + } + + default Predicate hasCategoryName(String name) { + return value -> value.toCategoryOut().name().equalsIgnoreCase(name); + } + + default void invalidate(CategorySlot slot) { + slot.init(); + slot.save(slot.initial()); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class CategorySlotBuilder implements SlotBuilder { + private final @Delegate CategoryQueries queries; + private @Getter @Setter @Nullable CategoryTemplate template = null; + + public CategorySlot blank() { + template = Optional.ofNullable(template).orElseGet(() -> categoryTemplate(categories.soup)); + return categoryContext().add(new CategorySlot(template)); + } + } +} + + diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategorySlot.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategorySlot.java new file mode 100644 index 0000000..da827ec --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategorySlot.java @@ -0,0 +1,48 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.category.ToCategoryOut; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class CategorySlot implements Comparable, EntitySlot, CategoryMask, ToCategoryOut { + public static EntityToken categoryToken = new EntityToken<>(2, CategorySlot.class); + public static Comparator order = comparing(CategorySlot::template); + private final @Delegate CategoryTemplate template; + private transient Category initial; + private transient @Delegate(types = ToCategoryOut.class) Category saved; + + public int compareTo(@Nullable CategorySlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + initial + "was initialized twice"); + initial = Category.initial(name()); + } + + public AnyEntityToken entityToken() { + return categoryToken; + } + + public void save(Category saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.empty(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTag.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTag.java new file mode 100644 index 0000000..e192740 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTag.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Tag; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record CategoryTag(int value) implements Tag, Comparable { + public static Comparator order = comparing(CategoryTag::value); + + public int compareTo(@Nullable CategoryTag other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTags.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTags.java new file mode 100644 index 0000000..5010d58 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTags.java @@ -0,0 +1,12 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Tags; + +public class CategoryTags extends Tags { + public static final CategoryTags categories = new CategoryTags(); + public final CategoryTag soup = tag(), dip = tag(), salad = tag(), pasta = tag(), baked = tag(); + + protected CategoryTag tag(int value) { + return new CategoryTag(value); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplate.java new file mode 100644 index 0000000..b9f69eb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplate.java @@ -0,0 +1,19 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record CategoryTemplate(CategoryTag tag, String name) + implements Comparable, CategoryMask, Template { + public static Comparator order = + comparing(CategoryTemplate::tag).thenComparing(CategoryTemplate::name); + + public int compareTo(@Nullable CategoryTemplate other) { + return order.compare(this, other); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplates.java new file mode 100644 index 0000000..f5ccc29 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplates.java @@ -0,0 +1,19 @@ +package eu.bitfield.recipes.test.core.category; + + +import eu.bitfield.recipes.test.data.TemplateContainer; + +import static eu.bitfield.recipes.test.core.category.CategoryTags.categories; + +public class CategoryTemplates extends TemplateContainer { + public CategoryTemplates() { + add(categories.dip, "Dip"); + add(categories.soup, "Soup"); + add(categories.salad, "Salad"); + add(categories.pasta, "Pasta"); + add(categories.baked, "Baked"); + } + + void add(CategoryTag tag, String name) {add(tag, new CategoryTemplate(tag, name));} +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientActions.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientActions.java new file mode 100644 index 0000000..9b28a35 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientActions.java @@ -0,0 +1,17 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +public interface IngredientActions { + Context ingredientContext(); + + MultiValueMap ingredientTemplatesByRecipe(); + + IngredientTemplate ingredientTemplate(RecipeTag recipeTag, long index); + + List ingredientTemplates(RecipeTag recipeTag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientLayer.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientLayer.java new file mode 100644 index 0000000..57d8908 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientLayer.java @@ -0,0 +1,32 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +import static java.util.Collections.*; + +@RequiredArgsConstructor +public class IngredientLayer implements IngredientActions { + private final IngredientTemplates templates = new IngredientTemplates(); + private final Context context = new Context<>(); + + public Context ingredientContext() { + return context; + } + + public MultiValueMap ingredientTemplatesByRecipe() { + return templates.byRecipe(); + } + + public IngredientTemplate ingredientTemplate(RecipeTag recipeTag, long index) { + return templates.get(new IngredientTemplates.Key(recipeTag, index)); + } + + public List ingredientTemplates(RecipeTag recipeTag) { + return ingredientTemplatesByRecipe().getOrDefault(recipeTag, emptyList()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientMask.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientMask.java new file mode 100644 index 0000000..6f52a44 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientMask.java @@ -0,0 +1,17 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.core.ingredient.IngredientIn; +import eu.bitfield.recipes.core.ingredient.ToIngredientIn; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; + +public interface IngredientMask extends ToIngredientIn { + RecipeTag recipeTag(); + + long index(); + + String name(); + + default IngredientIn toIngredientIn() { + return new IngredientIn(name()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientQueries.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientQueries.java new file mode 100644 index 0000000..e49f229 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientQueries.java @@ -0,0 +1,103 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeQueries; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Comparator.*; +import static java.util.stream.Collectors.*; + +public interface IngredientQueries extends RecipeQueries, IngredientActions { + default IngredientSlotBuilder ingredientSlot() { + return new IngredientSlotBuilder(this); + } + + default IngredientSlotBuilder ingredientSlot(IngredientTemplate template) { + return ingredientSlot().template(template); + } + + default IngredientSlotBuilder ingredientSlot(RecipeTag recipeTag, long index) { + return ingredientSlot(ingredientTemplate(recipeTag, index)); + } + + default Stream ingredientSlots(RecipeTag recipe) { + return ingredientTemplates(recipe).stream().map(this::ingredientSlot); + } + + default Stream ingredientSlots(RecipeSlot recipe) { + return ingredientSlots(recipe.tag()); + } + + default List recipeIngredientGroupsWithIngredientCounts(int... ingredientCounts) { + List recipeTagsInDescIngredientCountOrder = recipes.all() + .stream() + .sorted(comparing((RecipeTag recipeTag) -> ingredientTemplates(recipeTag).size())) + .collect(toCollection(ArrayList::new)); + List sortedIngredientCounts = IntStream.of(ingredientCounts).boxed().sorted(reverseOrder()).toList(); + MultiValueMap recipeTagsByIngredientCount = multiValueMap(); + for (int ingredientCount : sortedIngredientCounts) { + RecipeTag tag = recipeTagsInDescIngredientCountOrder.removeLast(); + recipeTagsByIngredientCount.add(ingredientCount, tag); + } + List groups = new ArrayList<>(); + for (int ingredientCount : ingredientCounts) { + RecipeTag recipeTag = recipeTagsByIngredientCount.get(ingredientCount).removeLast(); + groups.add(new RecipeIngredientGroup(recipeSlot(recipeTag), + ingredientSlots(recipeTag).limit(ingredientCount).toList())); + } + return groups; + } + + default RecipeIngredientGroup recipeIngredientGroupWithIngredientCount(int ingredientCount) { + return recipeIngredientGroupsWithIngredientCounts(ingredientCount).getFirst(); + } + + record RecipeIngredientGroup(RecipeSlotBuilder recipe, List ingredientList) { + public Stream ingredients() { + return ingredientList.stream(); + } + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class IngredientSlotBuilder implements SlotBuilder { + private final @Delegate IngredientQueries queries; + private @Getter @Setter @Nullable IngredientTemplate template = null; + private @Getter @Setter @Nullable RecipeSlot recipe = null; + + private void resolve() { + if (template != null) { + if (recipe != null && recipe.tag() != template.recipeTag()) { + throw new IllegalArgumentException("Recipe tag mismatch"); + } + + if (recipe == null) { + recipe = recipeSlot().template(recipeTemplate(template.recipeTag())).blank(); + } + return; + } + recipe = Optional.ofNullable(recipe).orElseGet(() -> recipeSlot().blank()); + template = Optional.ofNullable(template).orElseGet(() -> ingredientTemplate(recipe.tag(), 0)); + } + + public IngredientSlot blank() { + resolve(); + return ingredientContext().add(new IngredientSlot(template, recipe)); + } + } +} \ No newline at end of file diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientSlot.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientSlot.java new file mode 100644 index 0000000..bac820d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientSlot.java @@ -0,0 +1,52 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.ingredient.ToIngredientOut; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class IngredientSlot + implements Comparable, EntitySlot, IngredientMask, ToIngredientOut { + public static EntityToken ingredientToken = new EntityToken<>(5, IngredientSlot.class); + public static Comparator order = + comparing(IngredientSlot::template).thenComparing(IngredientSlot::recipe); + private final @Delegate IngredientTemplate template; + private final RecipeSlot recipe; + private transient Ingredient initial; + private transient @Delegate(types = ToIngredientOut.class) Ingredient saved; + + public int compareTo(@Nullable IngredientSlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = Ingredient.initial(recipe.id(), name()); + } + + public AnyEntityToken entityToken() { + return ingredientToken; + } + + public void save(Ingredient saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(recipe); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplate.java new file mode 100644 index 0000000..1687793 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplate.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record IngredientTemplate(RecipeTag recipeTag, long index, String name) + implements Comparable, IngredientMask, Template { + public static Comparator order = + comparing(IngredientTemplate::recipeTag) + .thenComparing(IngredientTemplate::index) + .thenComparing(IngredientTemplate::name); + + public int compareTo(@Nullable IngredientTemplate other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplates.java new file mode 100644 index 0000000..429482e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplates.java @@ -0,0 +1,60 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.TemplateContainer; +import org.springframework.util.MultiValueMap; + +import java.util.ArrayList; +import java.util.List; + +import static eu.bitfield.recipes.test.core.ingredient.IngredientTemplates.Key.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Collections.*; +import static org.springframework.util.CollectionUtils.*; + +public class IngredientTemplates extends TemplateContainer { + private final MultiValueMap byRecipe = multiValueMap(); + + public IngredientTemplates() { + add(recipes.minestrone, + names("Onions", "Carrots", "Celery", "Garlic", "Tomatoes", "Beans", "Pasta", "Kale", "Vegetables", + "Oregano", "Basil")); + add(recipes.hummus, names("Chickpeas", "Tahini", "Lemon juice", "Garlic")); + add(recipes.muhammara, + names("Pomegranate molasses", "Red bell peppers", "Bread crumbs", "Cumin", "Chili flakes", "Garlic", + "Walnuts", "Lemon juice")); + add(recipes.tomatoSalad, names("Tomatoes", "Onions", "Balsamico Vinegar")); + } + + private String[] names(String... names) {return names;} + + private void add(RecipeTag recipeTag, String[] names) { + long index = 0; + List templates = new ArrayList<>(names.length); + for (String name : names) { + var template = new IngredientTemplate(recipeTag, index, name); + add(key(template), template); + templates.add(template); + index++; + } + byRecipe.addAll(recipeTag, templates); + } + + public List byRecipe(RecipeTag recipeTag) { + return unmodifiableList(byRecipe.getOrDefault(recipeTag, emptyList())); + } + + public MultiValueMap byRecipe() { + return unmodifiableMultiValueMap(byRecipe); + } + + public record Key( + RecipeTag recipeTag, + long index + ) { + static Key key(IngredientTemplate template) { + return new Key(template.recipeTag(), template.index()); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/CategoryLinkGroup.java b/src/test/java/eu/bitfield/recipes/test/core/link/CategoryLinkGroup.java new file mode 100644 index 0000000..27d69a3 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/CategoryLinkGroup.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; + +import java.util.List; + +public record CategoryLinkGroup(CategoryTag categoryTag, List linkTemplates) { + public int linkCount() { + return linkTemplates.size(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatActions.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatActions.java new file mode 100644 index 0000000..6a452b5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatActions.java @@ -0,0 +1,20 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; + +import java.util.Collection; +import java.util.List; + +public interface LinkRecCatActions { + Context linkRecCatContext(); + + Collection linkTemplates(); + + LinkRecCatTemplate linkTemplate(RecipeTag recipeTag, CategoryTag categoryTag); + + List linkTemplates(RecipeTag recipeTag); + + List linkTemplates(CategoryTag categoryTag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatLayer.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatLayer.java new file mode 100644 index 0000000..5954930 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatLayer.java @@ -0,0 +1,31 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +public class LinkRecCatLayer implements LinkRecCatActions { + private final LinkRecCatTemplates templates = new LinkRecCatTemplates(); + private final Context context = new Context<>(); + + public Context linkRecCatContext() {return context;} + + public Collection linkTemplates() {return templates.all();} + + public LinkRecCatTemplate linkTemplate(RecipeTag recipeTag, CategoryTag categoryTag) { + return templates.get(recipeTag, categoryTag); + } + + public List linkTemplates(RecipeTag recipeTag) { + return templates.linkTemplatesByRecipeTag(recipeTag); + } + + public List linkTemplates(CategoryTag categoryTag) { + return templates.linkTemplatesByCategoryTag(categoryTag); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatMask.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatMask.java new file mode 100644 index 0000000..7d12ad7 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatMask.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; + +public interface LinkRecCatMask { + RecipeTag recipeTag(); + + CategoryTag categoryTag(); + + default Key key() { + return new Key(recipeTag(), categoryTag()); + } + + record Key(RecipeTag recipeTag, CategoryTag categoryTag) {} +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatQueries.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatQueries.java new file mode 100644 index 0000000..d707d4e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatQueries.java @@ -0,0 +1,156 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryQueries; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeQueries; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.stream.Stream; + +import static java.util.function.Function.*; +import static java.util.stream.Collectors.*; + +public interface LinkRecCatQueries extends LinkRecCatActions, CategoryQueries, RecipeQueries { + default LinkRecCatSlotBuilder linkRecCatSlot() { + return new LinkRecCatSlotBuilder(this); + } + + default LinkRecCatSlotBuilder linkRecCatSlot(RecipeTag recipeTag, CategoryTag categoryTag) { + return linkRecCatSlot(linkTemplate(recipeTag, categoryTag)); + } + + default LinkRecCatSlotBuilder linkRecCatSlot(LinkRecCatTemplate template) { + return linkRecCatSlot().template(template); + } + + default Stream linkRecCatSlots() { + return linkTemplates().stream().map(this::linkRecCatSlot); + } + + default Map> recipesByLinkCount() { + return recipeTemplates() + .stream() + .collect(toMap(identity(), recipe -> linkTemplates(recipe.tag()).size())) + .entrySet() + .stream() + .collect(groupingBy(Entry::getValue, mapping(entry -> recipeSlot(entry.getKey()), toList()))); + } + + default Map> categoriesByLinkCount() { + return categoryTemplates() + .stream() + .collect(toMap(identity(), category -> linkTemplates(category.tag()).size())) + .entrySet() + .stream() + .collect(groupingBy(Entry::getValue, mapping(entry -> categorySlot(entry.getKey()), toList()))); + } + + default Stream recipeSlotsWithLinkCounts(int... linkCounts) { + Map> recipesByLinkCount = recipesByLinkCount(); + List recipes = new ArrayList<>(); + for (int linkCount : linkCounts) { + recipes.add(recipesByLinkCount.get(linkCount).removeLast()); + } + return recipes.stream(); + } + + default RecipeSlotBuilder recipeSlotWithLinkCount(int linkCount) { + return recipeSlotsWithLinkCounts(linkCount).findFirst().orElseThrow(); + } + + default Stream categorySlotsWithLinkCounts(int... linkCounts) { + Map> categoriesByLinkCount = categoriesByLinkCount(); + List categories = new ArrayList<>(); + for (int linkCount : linkCounts) { + categories.add(categoriesByLinkCount.get(linkCount).removeLast()); + } + return categories.stream(); + } + + default CategorySlotBuilder categorySlotWithLinkCount(int linkCount) { + return categorySlotsWithLinkCounts(linkCount).findFirst().orElseThrow(); + } + + default Stream linkRecCatSlots(RecipeSlot recipe) { + return linkTemplates().stream() + .filter(template -> template.recipeTag() == recipe.tag()) + .map(template -> linkRecCatSlot(template).recipe(recipe)); + } + + default Stream linkRecCatSlots(CategorySlot category) { + return linkTemplates().stream() + .filter(template -> template.categoryTag() == category.tag()) + .map(template -> linkRecCatSlot(template).category(category)); + } + + default Stream linkedCategorySlots(RecipeTag recipeTag) { + return linkTemplates(recipeTag).stream().map(LinkRecCatTemplate::categoryTag).map(this::categorySlot); + } + + default Stream linkedCategorySlots(RecipeSlot recipe) { + return linkedCategorySlots(recipe.tag()); + } + + default Stream linkedRecipeSlots(CategoryTag categoryTag) { + return linkTemplates(categoryTag).stream().map(LinkRecCatTemplate::recipeTag).map(this::recipeSlot); + } + + default Stream linkedRecipeSlots(CategorySlot category) { + return linkedRecipeSlots(category.tag()); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class LinkRecCatSlotBuilder implements SlotBuilder { + private final @Delegate LinkRecCatQueries queries; + private @Getter @Setter @Nullable LinkRecCatTemplate template = null; + private @Getter @Setter @Nullable RecipeSlot recipe = null; + private @Getter @Setter @Nullable CategorySlot category = null; + + private void resolve() { + if (template != null) { + if (category != null && category.tag() != template.categoryTag()) { + throw new IllegalArgumentException("Category tag mismatch"); + } + if (recipe != null && recipe.tag() != template.recipeTag()) { + throw new IllegalArgumentException("Recipe tag mismatch"); + } + + if (category == null) { + category = categorySlot().template(categoryTemplate(template.categoryTag())).blank(); + } + if (recipe == null) { + recipe = recipeSlot().template(recipeTemplate(template.recipeTag())).blank(); + } + return; + } + recipe = Optional.ofNullable(recipe).orElseGet(() -> recipeSlot().blank()); + category = Optional.ofNullable(category).orElseGet(() -> categorySlot().blank()); + template = Optional.ofNullable(template).orElseGet(() -> { + assert recipe != null && category != null; + return linkTemplate(recipe.tag(), category.tag()); + }); + + } + + public LinkRecCatSlot blank() { + resolve(); + return linkRecCatContext().add(new LinkRecCatSlot(template, recipe, category)); + } + } +} + + diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatSlot.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatSlot.java new file mode 100644 index 0000000..d6b880b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatSlot.java @@ -0,0 +1,54 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class LinkRecCatSlot implements Comparable, EntitySlot, LinkRecCatMask { + public static final EntityToken linkRecCatToken = new EntityToken<>(7, LinkRecCatSlot.class); + public static Comparator order = + comparing(LinkRecCatSlot::template) + .thenComparing(LinkRecCatSlot::recipe) + .thenComparing(LinkRecCatSlot::category); + private final @Delegate LinkRecCatTemplate template; + private final RecipeSlot recipe; + private final CategorySlot category; + private transient LinkRecCat initial; + private transient LinkRecCat saved; + + public int compareTo(@Nullable LinkRecCatSlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = LinkRecCat.initial(recipe.id(), category.id()); + } + + public AnyEntityToken entityToken() { + return linkRecCatToken; + } + + public void save(LinkRecCat saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(recipe, category); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplate.java new file mode 100644 index 0000000..4510e5c --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplate.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record LinkRecCatTemplate(RecipeTag recipeTag, CategoryTag categoryTag) + implements Comparable, Template, LinkRecCatMask { + public static Comparator order = + comparing(LinkRecCatTemplate::recipeTag).thenComparing(LinkRecCatTemplate::categoryTag); + + public int compareTo(@Nullable LinkRecCatTemplate other) { + return order.compare(this, other); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplates.java new file mode 100644 index 0000000..55553d0 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplates.java @@ -0,0 +1,57 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.TemplateContainer; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +import static eu.bitfield.recipes.test.core.category.CategoryTags.*; +import static eu.bitfield.recipes.test.core.link.LinkRecCatTemplates.Key.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Collections.*; + +public class LinkRecCatTemplates + extends TemplateContainer { + private final MultiValueMap templatesByRecipeTag = multiValueMap(); + private final MultiValueMap templatesByCategoryTag = multiValueMap(); + + public LinkRecCatTemplates() { + add(recipes.minestrone, categories.soup, categories.pasta); + add(recipes.hummus, categories.dip); + add(recipes.muhammara, categories.dip); + add(recipes.tomatoSalad, categories.salad); + } + + private void add(RecipeTag recipeTag, CategoryTag... categoryTags) { + for (CategoryTag categoryTag : categoryTags) { + var template = new LinkRecCatTemplate(recipeTag, categoryTag); + add(key(template), template); + templatesByRecipeTag.add(recipeTag, template); + templatesByCategoryTag.add(categoryTag, template); + } + } + + public List linkTemplatesByRecipeTag(RecipeTag recipeTag) { + return unmodifiableList(templatesByRecipeTag.getOrDefault(recipeTag, emptyList())); + } + + public List linkTemplatesByCategoryTag(CategoryTag categoryTag) { + return unmodifiableList(templatesByCategoryTag.getOrDefault(categoryTag, emptyList())); + } + + public LinkRecCatTemplate get(RecipeTag recipeTag, CategoryTag categoryTag) { + return get(new Key(recipeTag, categoryTag)); + } + + public record Key( + RecipeTag recipeTag, + CategoryTag categoryTag + ) { + static Key key(LinkRecCatTemplate template) { + return new Key(template.recipeTag(), template.categoryTag()); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileActions.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileActions.java new file mode 100644 index 0000000..c9a08fb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileActions.java @@ -0,0 +1,9 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.Context; + +public interface ProfileActions { + Context profileContext(); + + ProfileTemplate profileTemplate(ProfileTag profileTag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileLayer.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileLayer.java new file mode 100644 index 0000000..a7c101b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileLayer.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ProfileLayer implements ProfileActions { + private final ProfileTemplates templates = new ProfileTemplates(); + private final Context context = new Context<>(); + + public Context profileContext() { + return context; + } + + public ProfileTemplate profileTemplate(ProfileTag profileTag) { + return templates.get(profileTag); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileMask.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileMask.java new file mode 100644 index 0000000..000e23e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileMask.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.core.profile; + +public interface ProfileMask { + ProfileTag tag(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileQueries.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileQueries.java new file mode 100644 index 0000000..ff57669 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileQueries.java @@ -0,0 +1,47 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Optional; + +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; + +public interface ProfileQueries extends ProfileActions { + default ProfileSlotBuilder profileSlot() { + return new ProfileSlotBuilder(this); + } + + default ProfileSlotBuilder profileSlot(ProfileTemplate template) { + return profileSlot().template(template); + } + + default ProfileSlotBuilder profileSlot(ProfileTag tag) { + return profileSlot(profileTemplate(tag)); + } + + default void invalidate(ProfileSlot slot) { + slot.init(); + slot.save(slot.initial()); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class ProfileSlotBuilder implements SlotBuilder { + private final @Delegate ProfileQueries queries; + private @Getter @Setter @Nullable ProfileTemplate template = null; + + public ProfileSlot blank() { + template = Optional.ofNullable(template).orElseGet(() -> profileTemplate(profiles.ada)); + return profileContext().add(new ProfileSlot(template)); + } + } +} + + + + diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileSlot.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileSlot.java new file mode 100644 index 0000000..c9a8811 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileSlot.java @@ -0,0 +1,49 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.core.profile.Profile; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class ProfileSlot implements Comparable, EntitySlot, ProfileMask { + public static EntityToken profileToken = new EntityToken<>(1, ProfileSlot.class); + public static Comparator order = comparing(ProfileSlot::template); + private final @Delegate ProfileTemplate template; + private transient Profile initial; + private transient Profile saved; + + public int compareTo(@Nullable ProfileSlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = Profile.initial(); + } + + public AnyEntityToken entityToken() { + return profileToken; + } + + public void save(Profile saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.empty(); + } +} + + diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTag.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTag.java new file mode 100644 index 0000000..6f8456e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTag.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.Tag; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record ProfileTag(int value) implements Tag, Comparable { + public static Comparator order = comparing(ProfileTag::value); + + public int compareTo(@Nullable ProfileTag other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTags.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTags.java new file mode 100644 index 0000000..01b1604 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTags.java @@ -0,0 +1,12 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.Tags; + +public class ProfileTags extends Tags { + public static final ProfileTags profiles = new ProfileTags(); + public final ProfileTag ada = tag(), bea = tag(), rio = tag(); + + protected ProfileTag tag(int value) { + return new ProfileTag(value); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplate.java new file mode 100644 index 0000000..388ea73 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplate.java @@ -0,0 +1,17 @@ +package eu.bitfield.recipes.test.core.profile; + + +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record ProfileTemplate(ProfileTag tag) implements Comparable, ProfileMask, Template { + public static Comparator order = comparing(ProfileTemplate::tag); + + public int compareTo(@Nullable ProfileTemplate other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplates.java new file mode 100644 index 0000000..0c03f2f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplates.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.test.core.profile; + + +import eu.bitfield.recipes.test.data.TemplateContainer; + +import static eu.bitfield.recipes.test.core.profile.ProfileTags.profiles; + +public class ProfileTemplates extends TemplateContainer { + public ProfileTemplates() { + for (ProfileTag tag : profiles.all()) { + add(tag, new ProfileTemplate(tag)); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeActions.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeActions.java new file mode 100644 index 0000000..3df2007 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeActions.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.test.data.Context; +import eu.bitfield.recipes.util.Chronology; + +import java.util.Collection; + +public interface RecipeActions { + Context recipeContext(); + + Collection recipeTemplates(); + + RecipeTemplate recipeTemplate(RecipeTag recipeTag); + + void init(RecipeSlot slot); + + Chronology realTime(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeLayer.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeLayer.java new file mode 100644 index 0000000..acb665b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeLayer.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.test.data.Context; +import eu.bitfield.recipes.test.data.SlotInitializer; +import eu.bitfield.recipes.util.Chronology; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.Collection; + +@RequiredArgsConstructor @Accessors(fluent = true) +public class RecipeLayer implements RecipeActions, SlotInitializer { + private final RecipeTemplates templates = new RecipeTemplates(); + private final @Getter Chronology realTime = new Chronology(); + private final Context context = new Context<>(); + + public Context recipeContext() { + return context; + } + + public Collection recipeTemplates() {return templates.all();} + + public RecipeTemplate recipeTemplate(RecipeTag recipeTag) {return templates.get(recipeTag);} + + public void init(RecipeSlot slot) { + slot.init(realTime.now()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeMask.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeMask.java new file mode 100644 index 0000000..45e638e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeMask.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeIn; +import eu.bitfield.recipes.core.recipe.ToRecipeIn; + +public interface RecipeMask extends ToRecipeIn { + RecipeTag tag(); + + String name(); + + String description(); + + default RecipeIn toRecipeIn() { + return new RecipeIn(name(), description()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeQueries.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeQueries.java new file mode 100644 index 0000000..3d4b952 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeQueries.java @@ -0,0 +1,71 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeIn; +import eu.bitfield.recipes.core.recipe.ToRecipeOut; +import eu.bitfield.recipes.test.core.profile.ProfileQueries; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static java.util.Comparator.*; + +public interface RecipeQueries extends RecipeActions, ProfileQueries { + default Predicate containsRecipeName(String recipeName) { + return (T value) -> value.toRecipeOut().name().toLowerCase().contains(recipeName.toLowerCase()); + } + + default Comparator orderByRecipeChangedAtDesc() { + return comparing((T value) -> value.toRecipe().changedAt()).reversed(); + } + + default RecipeSlotBuilder recipeSlot() { + return new RecipeSlotBuilder(this); + } + + default RecipeSlotBuilder recipeSlot(RecipeTemplate template) { + return recipeSlot().template(template); + } + + default RecipeSlotBuilder recipeSlot(RecipeTag tag) { + return recipeSlot(recipeTemplate(tag)); + } + + default Stream recipeSlots() { + return recipeTemplates().stream().map(this::recipeSlot); + } + + default void invalidate(RecipeSlot slot) { + invalidate(slot.author()); + init(slot); + slot.save(slot.initial()); + } + + default RecipeIn invalid(RecipeIn recipeIn) { + return recipeIn.withName(""); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class RecipeSlotBuilder implements SlotBuilder, RecipeMask { + private final @Delegate RecipeQueries queries; + private @Delegate @Getter @Setter @Nullable RecipeTemplate template = null; + private @Getter @Setter @Nullable ProfileSlot author = null; + + public RecipeSlot blank() { + template = Optional.ofNullable(template).orElseGet(() -> recipeTemplate(recipes.minestrone)); + author = Optional.ofNullable(author).orElseGet(() -> profileSlot().blank()); + return recipeContext().add((new RecipeSlot(template, author))); + } + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeSlot.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeSlot.java new file mode 100644 index 0000000..fd61294 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeSlot.java @@ -0,0 +1,55 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.time.Instant; +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class RecipeSlot implements Comparable, EntitySlot, RecipeMask, ToRecipe { + public static EntityToken recipeToken = new EntityToken<>(4, RecipeSlot.class); + public static Comparator order = comparing(RecipeSlot::template).thenComparing(RecipeSlot::author); + private final @Delegate RecipeTemplate template; + private final ProfileSlot author; + private transient Recipe initial; + private transient Recipe saved; + + public void init(Instant createdAt) { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = Recipe.initial(author.id(), name(), description(), createdAt); + } + + public AnyEntityToken entityToken() { + return recipeToken; + } + + public void save(Recipe saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(author); + } + + public int compareTo(@Nullable RecipeSlot other) { + return order.compare(this, other); + } + + public Recipe toRecipe() { + return saved; + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTag.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTag.java new file mode 100644 index 0000000..cd02077 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTag.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.test.data.Tag; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record RecipeTag(int value) implements Tag, Comparable { + public static Comparator order = comparing(RecipeTag::value); + + public int compareTo(@Nullable RecipeTag other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTags.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTags.java new file mode 100644 index 0000000..be57d4b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTags.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.test.core.recipe; + + +import eu.bitfield.recipes.test.data.Tags; + +public class RecipeTags extends Tags { + public static final RecipeTags recipes = new RecipeTags(); + public final RecipeTag minestrone = tag(), hummus = tag(), muhammara = tag(), tomatoSalad = tag(), + gigantesPlaki = tag(); + + protected RecipeTag tag(int value) { + return new RecipeTag(value); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplate.java new file mode 100644 index 0000000..b165a90 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplate.java @@ -0,0 +1,22 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.test.data.Template; +import lombok.With; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +@With +public record RecipeTemplate(RecipeTag tag, String name, String description) + implements Comparable, RecipeMask, Template { + public static Comparator order = + comparing(RecipeTemplate::tag) + .thenComparing(RecipeTemplate::name) + .thenComparing(RecipeTemplate::description); + + public int compareTo(@Nullable RecipeTemplate other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplates.java new file mode 100644 index 0000000..ace547a --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplates.java @@ -0,0 +1,36 @@ +package eu.bitfield.recipes.test.core.recipe; + + +import eu.bitfield.recipes.test.data.TemplateContainer; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; + +public class RecipeTemplates extends TemplateContainer { + public RecipeTemplates() { + add(recipes.minestrone, + "Minestrone", + "Minestrone or minestrone di verdure is a thick soup of Italian origin based on vegetables. It typically " + + "includes onions, carrots, celery, potatoes, cabbage, tomatoes, often legumes, such as beans, chickpeas " + + "or fava beans, and sometimes pasta or rice."); + add(recipes.hummus, + "Hummus", + "Hummus, is a Levantine dip, spread, or savory dish made from cooked, mashed chickpeas blended with " + + "tahini, lemon juice, and garlic."); + add(recipes.muhammara, + "Muhammara", + "Muhammara is a vibrant and flavorful dip or spread from the Middle East, particularly popular in Syrian " + + "and Turkish cuisine. It's made primarily from roasted red bell peppers, walnuts, breadcrumbs, and " + + "pomegranate molasses."); + add(recipes.tomatoSalad, + "Tomato Salad", + "A tomato salad is a light side dish filled with juicy ripe tomatoes and thinly sliced onions."); + add(recipes.gigantesPlaki, + "Gigantes Plaki", + "Gigantes plaki or Greek giant baked beans, is a Greek dish of large white beans baked in a tomato sauce."); + + } + + void add(RecipeTag tag, String name, String description) { + add(tag, new RecipeTemplate(tag, name, description)); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/ToRecipe.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/ToRecipe.java new file mode 100644 index 0000000..7e2a1eb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/ToRecipe.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.recipe.RecipeOut; +import eu.bitfield.recipes.core.recipe.ToRecipeOut; + +public interface ToRecipe extends ToRecipeOut { + Recipe toRecipe(); + + default RecipeOut toRecipeOut() { + return toRecipe().toRecipeOut(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepActions.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepActions.java new file mode 100644 index 0000000..f6e792e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepActions.java @@ -0,0 +1,17 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +public interface StepActions { + Context stepContext(); + + MultiValueMap stepTemplates(); + + StepTemplate stepTemplate(RecipeTag recipeTag, long index); + + List stepTemplates(RecipeTag recipeTag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepLayer.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepLayer.java new file mode 100644 index 0000000..9fb533e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepLayer.java @@ -0,0 +1,32 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +import static java.util.Collections.*; + +@RequiredArgsConstructor +public class StepLayer implements StepActions { + private final StepTemplates templates = new StepTemplates(); + private final Context context = new Context<>(); + + public Context stepContext() { + return context; + } + + public MultiValueMap stepTemplates() { + return templates.byRecipe(); + } + + public StepTemplate stepTemplate(RecipeTag recipeTag, long index) { + return templates.get(new StepTemplates.Key(recipeTag, index)); + } + + public List stepTemplates(RecipeTag recipeTag) { + return stepTemplates().getOrDefault(recipeTag, emptyList()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepMask.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepMask.java new file mode 100644 index 0000000..5d93c59 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepMask.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.core.step.StepIn; +import eu.bitfield.recipes.core.step.ToStepIn; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; + + +public interface StepMask extends ToStepIn { + RecipeTag recipeTag(); + + long index(); + + String name(); + + default StepIn toStepIn() { + return new StepIn(name()); + } +} \ No newline at end of file diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepQueries.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepQueries.java new file mode 100644 index 0000000..71e4ef5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepQueries.java @@ -0,0 +1,103 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.test.core.recipe.RecipeQueries; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Comparator.*; +import static java.util.stream.Collectors.*; + +public interface StepQueries extends RecipeQueries, StepActions { + default StepSlotBuilder stepSlot() { + return new StepSlotBuilder(this); + } + + default StepSlotBuilder stepSlot(StepTemplate template) { + return stepSlot().template(template); + } + + default StepSlotBuilder stepSlot(RecipeTag recipeTag, long index) { + return stepSlot(stepTemplate(recipeTag, index)); + } + + default Stream stepSlots(RecipeTag recipe) { + return stepTemplates(recipe).stream().map(this::stepSlot); + } + + default Stream stepSlots(RecipeSlot recipe) { + return stepSlots(recipe.tag()); + } + + default List recipeStepGroupsWithStepCounts(int... stepCounts) { + List recipeTagsInDescStepCountOrder = recipes.all() + .stream() + .sorted(comparing((RecipeTag recipeTag) -> stepTemplates(recipeTag).size())) + .collect(toCollection(ArrayList::new)); + List sortedStepCounts = IntStream.of(stepCounts).boxed().sorted(reverseOrder()).toList(); + MultiValueMap recipeTagsByStepCount = multiValueMap(); + for (int stepCount : sortedStepCounts) { + RecipeTag tag = recipeTagsInDescStepCountOrder.removeLast(); + recipeTagsByStepCount.add(stepCount, tag); + } + List groups = new ArrayList<>(); + for (int stepCount : stepCounts) { + RecipeTag recipeTag = recipeTagsByStepCount.get(stepCount).removeLast(); + groups.add(new RecipeStepGroup(recipeSlot(recipeTag), stepSlots(recipeTag).limit(stepCount).toList())); + } + return groups; + } + + default RecipeStepGroup recipeStepGroupWithStepCount(int stepCount) { + return recipeStepGroupsWithStepCounts(stepCount).getFirst(); + } + + record RecipeStepGroup(RecipeSlotBuilder recipe, List stepList) { + public Stream steps() { + return stepList.stream(); + } + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class StepSlotBuilder implements SlotBuilder { + private final @Delegate StepQueries queries; + private @Getter @Setter @Nullable StepTemplate template = null; + private @Getter @Setter @Nullable RecipeSlot recipe = null; + + private void resolve() { + if (template != null) { + if (recipe != null && recipe.tag() != template.recipeTag()) { + throw new IllegalArgumentException("Recipe tag mismatch"); + } + + if (recipe == null) { + recipe = recipeSlot().template(recipeTemplate(template.recipeTag())).blank(); + } + return; + } + recipe = Optional.ofNullable(recipe).orElseGet(() -> recipeSlot().blank()); + template = Optional.ofNullable(template).orElseGet(() -> stepTemplate(recipe.tag(), 0)); + } + + public StepSlot blank() { + resolve(); + return stepContext().add(new StepSlot(template, recipe)); + } + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepSlot.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepSlot.java new file mode 100644 index 0000000..b990d1b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepSlot.java @@ -0,0 +1,50 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.core.step.ToStepOut; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class StepSlot implements Comparable, EntitySlot, StepMask, ToStepOut { + public static EntityToken stepToken = new EntityToken<>(6, StepSlot.class); + public static Comparator order = comparing(StepSlot::template).thenComparing(StepSlot::recipe); + private final @Delegate StepTemplate template; + private final RecipeSlot recipe; + private transient Step initial; + private transient @Delegate(types = ToStepOut.class) Step saved; + + public int compareTo(@Nullable StepSlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = Step.initial(recipe.id(), name()); + } + + public AnyEntityToken entityToken() { + return stepToken; + } + + public void save(Step saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(recipe); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplate.java new file mode 100644 index 0000000..7edb464 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplate.java @@ -0,0 +1,23 @@ +package eu.bitfield.recipes.test.core.step; + + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record StepTemplate(RecipeTag recipeTag, long index, String name) + implements Comparable, StepMask, Template { + public static Comparator order = + comparing(StepTemplate::recipeTag) + .thenComparing(StepTemplate::index) + .thenComparing(StepTemplate::name); + + public int compareTo(@Nullable StepTemplate other) { + return order.compare(this, other); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplates.java new file mode 100644 index 0000000..08ef0ea --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplates.java @@ -0,0 +1,65 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.TemplateContainer; +import org.springframework.util.MultiValueMap; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.test.core.step.StepTemplates.Key.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static org.springframework.util.CollectionUtils.*; + +public class StepTemplates extends TemplateContainer { + private final MultiValueMap byRecipe = multiValueMap(); + + public StepTemplates() { + add(recipes.minestrone, + names("Sauté the chopped onion, carrots, and celery for 5-7 minutes", + "Add the minced garlic and cook for another minute until fragrant.", + "Add the pasta, vegetables and tomatoes to the pot.", + "Bring to a simmer, then reduce heat, cover and wait until the pasta is al dente and the vegetables" + + " are tender.", + "Stir in the kale, near the end of the cooking time.")); + add(recipes.hummus, + names("Combine chickpeas, tahini, lemon juice, garlic, and salt in a food processor.", + "Blend until smooth, slowly adding cold water until you reach a creamy consistency.", + "Taste and adjust seasoning if needed.")); + + add(recipes.muhammara, + names("Roast whole bell peppers in an oven or air fryer and wait until blackened on the outside.", + "Put the peppers in a bowl cover them with to let steam and cool down", + "Remove core seeds, and skins", + "Combine all the ingredients in a food processor.", + "Process until mostly smooth but with a little texture remaining.", + "Taste and adjust flavor as needed (lemon for acidity, pomegranate molasses for sweetness)")); + + add(recipes.tomatoSalad, + names("Cut tomatoes in irregular pieces.", + "Finely chop the onions.", + "Combine with some balsamic vinegar.")); + } + + private String[] names(String... names) {return names;} + + private void add(RecipeTag recipeTag, String[] names) { + long index = 0; + for (String name : names) { + var template = new StepTemplate(recipeTag, index, name); + add(key(template), template); + byRecipe.add(recipeTag, template); + index++; + } + } + + public MultiValueMap byRecipe() {return unmodifiableMultiValueMap(byRecipe);} + + public record Key( + RecipeTag recipeTag, + long index + ) { + public static Key key(StepTemplate template) { + return new Key(template.recipeTag(), template.index()); + } + } + +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AnyAsyncEntityStorage.java b/src/test/java/eu/bitfield/recipes/test/data/AnyAsyncEntityStorage.java new file mode 100644 index 0000000..0c79c14 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AnyAsyncEntityStorage.java @@ -0,0 +1,12 @@ +package eu.bitfield.recipes.test.data; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface AnyAsyncEntityStorage { + AnyEntityToken entityToken(); + + Mono init(Flux> slots); + + Mono save(Flux> slots); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AnyEntityStorage.java b/src/test/java/eu/bitfield/recipes/test/data/AnyEntityStorage.java new file mode 100644 index 0000000..287515e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AnyEntityStorage.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.test.data; + +import java.util.stream.Stream; + +public interface AnyEntityStorage { + AnyEntityToken entityToken(); + + void init(Stream> slots); + + void save(Stream> slots); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AnyEntityToken.java b/src/test/java/eu/bitfield/recipes/test/data/AnyEntityToken.java new file mode 100644 index 0000000..2e122bb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AnyEntityToken.java @@ -0,0 +1,7 @@ +package eu.bitfield.recipes.test.data; + +public interface AnyEntityToken { + int id(); + + Class> slotType(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AsyncEntityStorage.java b/src/test/java/eu/bitfield/recipes/test/data/AsyncEntityStorage.java new file mode 100644 index 0000000..7308d56 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AsyncEntityStorage.java @@ -0,0 +1,36 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.test.AsyncPersistence; +import eu.bitfield.recipes.util.Entity; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RequiredArgsConstructor @Accessors(fluent = true) +public class AsyncEntityStorage, E extends Entity> implements AnyAsyncEntityStorage { + private final EntityToken token; + private final SlotInitializer initialization; + private final AsyncPersistence persistence; + + public Mono save(S slot) { + return Mono.defer(() -> { + initialization.init(slot); + return persistence.save(slot.initial()) + .doOnNext(slot::save) + .thenReturn(slot); + }); + } + + public AnyEntityToken entityToken() { + return token; + } + + public Mono init(Flux> slots) { + return slots.cast(token.slotType()).doOnNext(initialization::init).count(); + } + + public Mono save(Flux> slots) { + return slots.cast(token.slotType()).concatMap(this::save).count(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AsyncLayerFactory.java b/src/test/java/eu/bitfield/recipes/test/data/AsyncLayerFactory.java new file mode 100644 index 0000000..89ed2f5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AsyncLayerFactory.java @@ -0,0 +1,85 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.core.account.Account; +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.core.profile.Profile; +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.test.AsyncPersistence; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +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.step.StepLayer; +import eu.bitfield.recipes.test.core.step.StepSlot; +import lombok.RequiredArgsConstructor; + +import static eu.bitfield.recipes.test.core.account.AccountSlot.*; +import static eu.bitfield.recipes.test.core.category.CategorySlot.*; +import static eu.bitfield.recipes.test.core.ingredient.IngredientSlot.*; +import static eu.bitfield.recipes.test.core.link.LinkRecCatSlot.*; +import static eu.bitfield.recipes.test.core.profile.ProfileSlot.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeSlot.*; +import static eu.bitfield.recipes.test.core.step.StepSlot.*; + +@RequiredArgsConstructor +public class AsyncLayerFactory { + private final AsyncRootStorage rootStorage; + + public ProfileLayer profileLayer(AsyncPersistence persistence) { + var layer = new ProfileLayer(); + var storage = new AsyncEntityStorage<>(profileToken, ProfileSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public CategoryLayer categoryLayer(AsyncPersistence persistence) { + var layer = new CategoryLayer(); + var storage = new AsyncEntityStorage<>(categoryToken, CategorySlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public AccountLayer accountLayer(AsyncPersistence persistence) { + var layer = new AccountLayer(); + var storage = new AsyncEntityStorage<>(accountToken, layer, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public RecipeLayer recipeLayer(AsyncPersistence persistence) { + var layer = new RecipeLayer(); + var storage = new AsyncEntityStorage<>(recipeToken, layer, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public IngredientLayer ingredientLayer(AsyncPersistence persistence) { + var layer = new IngredientLayer(); + var storage = new AsyncEntityStorage<>(ingredientToken, IngredientSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public StepLayer stepLayer(AsyncPersistence persistence) { + var layer = new StepLayer(); + var storage = new AsyncEntityStorage<>(stepToken, StepSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public LinkRecCatLayer linkRecCatLayer(AsyncPersistence persistence) { + var layer = new LinkRecCatLayer(); + var storage = new AsyncEntityStorage<>(linkRecCatToken, LinkRecCatSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AsyncRootStorage.java b/src/test/java/eu/bitfield/recipes/test/data/AsyncRootStorage.java new file mode 100644 index 0000000..9c9017f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AsyncRootStorage.java @@ -0,0 +1,108 @@ +package eu.bitfield.recipes.test.data; + +import lombok.RequiredArgsConstructor; +import org.springframework.util.MultiValueMap; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.*; + +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Comparator.*; +import static java.util.stream.Collectors.*; +import static org.springframework.data.util.Predicates.*; + +public class AsyncRootStorage { + private final HashMap storageByType = new HashMap<>(); + + + public void addStorage(AnyAsyncEntityStorage storage) { + storageByType.put(storage.entityToken(), storage); + } + + public Mono save(EntitySlots slots) { + return Flux.fromIterable(slots.items()) + .filter(negate(EntitySlot::isSaved)) + .expand((EntitySlot slot) -> { + return Flux.fromStream(slot.saveDependencies()) + .filter(negate(EntitySlot::isSaved)); + }) + .collect(toCollection(HashSet::new)) + .map(SaveTask::new) + .repeat() + .takeWhile(SaveTask::unfinished) + .concatMap((SaveTask task) -> { + MultiValueMap> saveSlotsByType = + MultiValueMap.fromMultiValue(new TreeMap<>(comparing(AnyEntityToken::id))); + Iterator> slotIt = task.remainingSlotsToSave.iterator(); + while (slotIt.hasNext()) { + EntitySlot slot = slotIt.next(); + if (slot.isSavable()) { + slotIt.remove(); + saveSlotsByType.add(slot.entityToken(), slot); + } + } + if (saveSlotsByType.isEmpty() && task.prevSaveCount == 0) { + return Mono.error(new RuntimeException("No save progress could be made")); + } + return Flux.fromIterable(saveSlotsByType.entrySet()) + .concatMap(entryFn((AnyEntityToken type, List> saveSlotsTypeGroup) -> { + AnyAsyncEntityStorage storage = storageByType.get(type); + return storage.save(Flux.fromIterable(saveSlotsTypeGroup).sort()); + })) + .reduce(Long::sum) + .map((Long saveCount) -> { + task.prevSaveCount = saveCount; + return task; + }) + .then(); + }) + .then() + .cache(); + } + + public Mono init(EntitySlots slots) { + return Flux.fromIterable(slots.items()) + .filter(negate(EntitySlot::isInitialized)) + .collect(toCollection(HashSet::new)) + .delayUntil((HashSet> initSlots) -> { + return Flux.fromIterable(initSlots) + .map(EntitySlot::saveDependencies) + .concatMap(Flux::fromStream) + .collectList() + .map(EntitySlots::new) + .flatMap(this::save); + }) + .flatMapMany((HashSet> initSlots) -> { + MultiValueMap> initSlotsByType = + MultiValueMap.fromMultiValue(new TreeMap<>(comparing(AnyEntityToken::id))); + Iterator> slotIt = initSlots.iterator(); + while (slotIt.hasNext()) { + EntitySlot slot = slotIt.next(); + if (!slot.isInitializable()) { + return Flux.error(new RuntimeException("Slot " + slot + "can't be initialized")); + } + slotIt.remove(); + initSlotsByType.add(slot.entityToken(), slot); + } + return Flux.fromIterable(initSlotsByType.entrySet()); + }) + .concatMap(entryFn((AnyEntityToken type, List> initSlotsTypeGroup) -> { + AnyAsyncEntityStorage storage = storageByType.get(type); + return storage.init(Flux.fromIterable(initSlotsTypeGroup).sort()); + })) + .then() + .cache(); + + } + + @RequiredArgsConstructor + private static class SaveTask { + final Set> remainingSlotsToSave; + long prevSaveCount = 0; + + boolean unfinished() { + return !remainingSlotsToSave.isEmpty(); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Context.java b/src/test/java/eu/bitfield/recipes/test/data/Context.java new file mode 100644 index 0000000..de34b83 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Context.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.test.data; + +import java.util.LinkedHashMap; + +import static java.util.function.Function.*; + +public class Context { + private final LinkedHashMap map = new LinkedHashMap<>(); + + public E add(E e) { + return map.computeIfAbsent(e, identity()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/EntitySlot.java b/src/test/java/eu/bitfield/recipes/test/data/EntitySlot.java new file mode 100644 index 0000000..5c10e7e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/EntitySlot.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.util.Entity; + +import java.util.stream.Stream; + +public interface EntitySlot extends SavedEntity { + AnyEntityToken entityToken(); + + void save(E saved); + + default boolean isInitialized() { + return this.initial() != null; + } + + default boolean isSaved() { + return this.saved() != null; + } + + default boolean isInitializable() { + return saveDependencies().allMatch(EntitySlot::isSaved); + } + + default boolean isSavable() { + return isInitializable(); + } + + Stream> saveDependencies(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/EntitySlots.java b/src/test/java/eu/bitfield/recipes/test/data/EntitySlots.java new file mode 100644 index 0000000..1b34a6f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/EntitySlots.java @@ -0,0 +1,71 @@ +package eu.bitfield.recipes.test.data; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collector; + +import static eu.bitfield.recipes.util.To.*; +import static java.util.stream.Collectors.*; + +public record EntitySlots(Set> items) { + public EntitySlots(Collection> items) { + this(new HashSet<>(items)); + } + + public EntitySlots() { + this(new HashSet<>()); + } + + public static EntitySlots combinedSlots(Collection values) { + return values.stream().map(toSlots).collect(combineSlots()); + } + + public static EntitySlots combinedSlots(ToEntitySlots... values) { + return combinedSlots(List.of(values)); + } + + public static EntitySlots slots(Collection> slots) { + return new EntitySlots(slots); + } + + private static Collector combineSlots() { + return collectingAndThen(reducing(EntitySlots::add), + slots -> slots.orElseGet(EntitySlots::new)); + } + + public static EntitySlots slots(EntitySlot... slots) { + return slots(List.of(slots)); + } + + public static EntitySlots slots(ToEntitySlots value) { + return value.toEntitySlots(); + } + + public static EntitySlots slot(EntitySlot slot) { + return new EntitySlots(List.of(slot)); + } + + public EntitySlots add(EntitySlots slots) { + return add(slots.items); + } + + public EntitySlots add(EntitySlot... slots) { + return add(List.of(slots)); + } + + public EntitySlots add(ToEntitySlots value) { + return add(value.toEntitySlots()); + } + + public EntitySlots add(Collection> slots) { + items.addAll(slots); + return this; + } + + public EntitySlots add(EntitySlot slot) { + items.add(slot); + return this; + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/EntityStorage.java b/src/test/java/eu/bitfield/recipes/test/data/EntityStorage.java new file mode 100644 index 0000000..732ab87 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/EntityStorage.java @@ -0,0 +1,35 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.test.Persistence; +import eu.bitfield.recipes.util.Entity; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.stream.Stream; + +@RequiredArgsConstructor @Accessors(fluent = true) +public class EntityStorage, E extends Entity> implements AnyEntityStorage { + private final EntityToken token; + private final SlotInitializer initialization; + private final Persistence persistence; + + public void save(S slot) { + initialization.init(slot); + slot.save(persistence.save(slot.initial())); + } + + public AnyEntityToken entityToken() { + return token; + } + + public void init(Stream> slots) { + slots.forEach(anySlot -> { + S slot = token.slotType().cast(anySlot); + initialization.init(slot); + }); + } + + public void save(Stream> slots) { + slots.map(token.slotType()::cast).forEach(this::save); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/EntityToken.java b/src/test/java/eu/bitfield/recipes/test/data/EntityToken.java new file mode 100644 index 0000000..a71f827 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/EntityToken.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.test.data; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) +public class EntityToken> implements AnyEntityToken { + private final int id; + private final Class slotType; +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Initial.java b/src/test/java/eu/bitfield/recipes/test/data/Initial.java new file mode 100644 index 0000000..7c6e08f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Initial.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface Initial { + T initial(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/InitialEntity.java b/src/test/java/eu/bitfield/recipes/test/data/InitialEntity.java new file mode 100644 index 0000000..2e40487 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/InitialEntity.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.util.Entity; + +public interface InitialEntity extends Initial {} diff --git a/src/test/java/eu/bitfield/recipes/test/data/LayerFactory.java b/src/test/java/eu/bitfield/recipes/test/data/LayerFactory.java new file mode 100644 index 0000000..59b9e0e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/LayerFactory.java @@ -0,0 +1,113 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.core.account.Account; +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.core.profile.Profile; +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.test.Persistence; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +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.step.StepLayer; +import eu.bitfield.recipes.test.core.step.StepSlot; +import lombok.RequiredArgsConstructor; + +import static eu.bitfield.recipes.test.core.account.AccountSlot.*; +import static eu.bitfield.recipes.test.core.category.CategorySlot.*; +import static eu.bitfield.recipes.test.core.ingredient.IngredientSlot.*; +import static eu.bitfield.recipes.test.core.link.LinkRecCatSlot.*; +import static eu.bitfield.recipes.test.core.profile.ProfileSlot.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeSlot.*; +import static eu.bitfield.recipes.test.core.step.StepSlot.*; + +@RequiredArgsConstructor +public class LayerFactory { + private final RootStorage rootStorage; + + public ProfileLayer profileLayer(Persistence persistence) { + var layer = new ProfileLayer(); + var storage = new EntityStorage<>(profileToken, ProfileSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public ProfileLayer profileLayer() { + return profileLayer(Persistence.persistence(Profile::withId)); + } + + public CategoryLayer categoryLayer(Persistence persistence) { + var layer = new CategoryLayer(); + var storage = new EntityStorage<>(categoryToken, CategorySlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public CategoryLayer categoryLayer() { + return categoryLayer(Persistence.persistence(Category::withId)); + } + + public AccountLayer accountLayer(Persistence persistence) { + var layer = new AccountLayer(); + var storage = new EntityStorage<>(accountToken, layer, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public AccountLayer accountLayer() { + return accountLayer(Persistence.persistence(Account::withId)); + } + + public RecipeLayer recipeLayer(Persistence persistence) { + var layer = new RecipeLayer(); + var storage = new EntityStorage<>(recipeToken, layer, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public RecipeLayer recipeLayer() { + return recipeLayer(Persistence.persistence(Recipe::withId)); + } + + public IngredientLayer ingredientLayer(Persistence persistence) { + var layer = new IngredientLayer(); + var storage = new EntityStorage<>(ingredientToken, IngredientSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public IngredientLayer ingredientLayer() { + return ingredientLayer(Persistence.persistence(Ingredient::withId)); + } + + public StepLayer stepLayer(Persistence persistence) { + var layer = new StepLayer(); + var storage = new EntityStorage<>(stepToken, StepSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public StepLayer stepLayer() { + return stepLayer(Persistence.persistence(Step::withId)); + } + + public LinkRecCatLayer linkRecCatLayer(Persistence persistence) { + var layer = new LinkRecCatLayer(); + var storage = new EntityStorage<>(linkRecCatToken, LinkRecCatSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public LinkRecCatLayer linkRecCatLayer() { + return linkRecCatLayer(Persistence.persistence(LinkRecCat::withId)); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/RootStorage.java b/src/test/java/eu/bitfield/recipes/test/data/RootStorage.java new file mode 100644 index 0000000..ddd38f0 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/RootStorage.java @@ -0,0 +1,77 @@ +package eu.bitfield.recipes.test.data; + +import org.springframework.util.MultiValueMap; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Comparator.*; +import static org.springframework.data.util.Predicates.*; + +public class RootStorage { + private final HashMap storageByType = new HashMap<>(); + + + public void addStorage(AnyEntityStorage storage) { + storageByType.put(storage.entityToken(), storage); + } + + private void visitSavableSlots(EntitySlot slot, Consumer> visitor) { + if (slot.isSaved()) return; + visitor.accept(slot); + slot.saveDependencies().forEach(saveDependency -> visitSavableSlots(saveDependency, visitor)); + } + + public void save(EntitySlots slots) { + var t = this; + Set> remainingSlots = slots.items().stream().mapMulti(t::visitSavableSlots).collect(toHashSet()); + while (!remainingSlots.isEmpty()) { + MultiValueMap> slotsByType = + multiValueMap(new TreeMap<>(comparing(AnyEntityToken::id))); + Iterator> slotIt = remainingSlots.iterator(); + while (slotIt.hasNext()) { + EntitySlot slot = slotIt.next(); + if (slot.isSavable()) { + slotIt.remove(); + slotsByType.add(slot.entityToken(), slot); + } + } + if (slotsByType.isEmpty()) { + throw new RuntimeException("No save progress could be made"); + } + + for (var entry : slotsByType.entrySet()) { + AnyEntityToken type = entry.getKey(); + List> savableSlots = entry.getValue(); + AnyEntityStorage storage = storageByType.get(type); + storage.save(savableSlots.stream().sorted()); + } + } + } + + public void init(EntitySlots slots) { + Predicate> notInitialized = negate(EntitySlot::isInitialized); + Set> initSlots = slots.items().stream().filter(notInitialized).collect(toHashSet()); + save(EntitySlots.slots(initSlots.stream().flatMap(EntitySlot::saveDependencies).toList())); + MultiValueMap> slotsByType = + multiValueMap(new TreeMap<>(comparing(AnyEntityToken::id))); + Iterator> slotIt = initSlots.iterator(); + while (slotIt.hasNext()) { + EntitySlot slot = slotIt.next(); + if (!slot.isInitializable()) { + throw new RuntimeException("Slot " + slot + "can't be initialized"); + } + slotIt.remove(); + slotsByType.add(slot.entityToken(), slot); + } + + for (var entry : slotsByType.entrySet()) { + AnyEntityToken type = entry.getKey(); + List> savableSlots = entry.getValue(); + AnyEntityStorage storage = storageByType.get(type); + storage.init(savableSlots.stream().sorted()); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Saved.java b/src/test/java/eu/bitfield/recipes/test/data/Saved.java new file mode 100644 index 0000000..4e75168 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Saved.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface Saved { + T saved(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/SavedEntity.java b/src/test/java/eu/bitfield/recipes/test/data/SavedEntity.java new file mode 100644 index 0000000..92358f8 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/SavedEntity.java @@ -0,0 +1,10 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.util.Entity; +import eu.bitfield.recipes.util.Id; + +public interface SavedEntity extends Saved, Id, InitialEntity { + default long id() { + return saved().id(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/SlotBuilder.java b/src/test/java/eu/bitfield/recipes/test/data/SlotBuilder.java new file mode 100644 index 0000000..10a88d1 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/SlotBuilder.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface SlotBuilder> { + S blank(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/SlotInitializer.java b/src/test/java/eu/bitfield/recipes/test/data/SlotInitializer.java new file mode 100644 index 0000000..383d55a --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/SlotInitializer.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface SlotInitializer> { + void init(S slot); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Tag.java b/src/test/java/eu/bitfield/recipes/test/data/Tag.java new file mode 100644 index 0000000..5ad7888 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Tag.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface Tag { + int value(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Tags.java b/src/test/java/eu/bitfield/recipes/test/data/Tags.java new file mode 100644 index 0000000..c0963c4 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Tags.java @@ -0,0 +1,24 @@ +package eu.bitfield.recipes.test.data; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Collections.*; + + +public abstract class Tags { + private final List tags = new ArrayList<>(); + private int counter = 0; + + abstract protected T tag(int value); + + protected T tag() { + T tag = tag(counter++); + tags.add(tag); + return tag; + } + + public List all() { + return unmodifiableList(tags); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Template.java b/src/test/java/eu/bitfield/recipes/test/data/Template.java new file mode 100644 index 0000000..5378229 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Template.java @@ -0,0 +1,3 @@ +package eu.bitfield.recipes.test.data; + +public interface Template {} diff --git a/src/test/java/eu/bitfield/recipes/test/data/TemplateContainer.java b/src/test/java/eu/bitfield/recipes/test/data/TemplateContainer.java new file mode 100644 index 0000000..0c86801 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/TemplateContainer.java @@ -0,0 +1,22 @@ +package eu.bitfield.recipes.test.data; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import static java.util.Collections.unmodifiableCollection; +import static org.assertj.core.api.Assertions.assertThat; + +public class TemplateContainer { + private final Map templates = new HashMap<>(); + + protected T add(K key, T template) { + T prev = templates.put(key, template); + assertThat(prev).isNull(); + return template; + } + + public final Collection all() {return unmodifiableCollection(templates.values());} + + public T get(K key) {return templates.get(key);} +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Templates.java b/src/test/java/eu/bitfield/recipes/test/data/Templates.java new file mode 100644 index 0000000..46d8c9d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Templates.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.test.core.account.AccountTemplates; +import eu.bitfield.recipes.test.core.category.CategoryTemplates; +import eu.bitfield.recipes.test.core.ingredient.IngredientTemplates; +import eu.bitfield.recipes.test.core.link.LinkRecCatTemplates; +import eu.bitfield.recipes.test.core.profile.ProfileTemplates; +import eu.bitfield.recipes.test.core.recipe.RecipeTemplates; +import eu.bitfield.recipes.test.core.step.StepTemplates; +import org.springframework.stereotype.Component; + +@Component +public class Templates { + public final ProfileTemplates profile = new ProfileTemplates(); + public final AccountTemplates account = new AccountTemplates(); + public final RecipeTemplates recipe = new RecipeTemplates(); + public final IngredientTemplates ingredient = new IngredientTemplates(); + public final StepTemplates step = new StepTemplates(); + public final CategoryTemplates category = new CategoryTemplates(); + public final LinkRecCatTemplates link = new LinkRecCatTemplates(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/ToEntitySlots.java b/src/test/java/eu/bitfield/recipes/test/data/ToEntitySlots.java new file mode 100644 index 0000000..f765d99 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/ToEntitySlots.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface ToEntitySlots { + EntitySlots toEntitySlots(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/view/recipe/RecipeViewQueries.java b/src/test/java/eu/bitfield/recipes/test/view/recipe/RecipeViewQueries.java new file mode 100644 index 0000000..a03b63e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/view/recipe/RecipeViewQueries.java @@ -0,0 +1,104 @@ +package eu.bitfield.recipes.test.view.recipe; + +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.category.CategoryIn; +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.ingredient.IngredientIn; +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.recipe.RecipeIn; +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.core.step.StepIn; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.ingredient.IngredientQueries; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatQueries; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.ToRecipe; +import eu.bitfield.recipes.test.core.step.StepQueries; +import eu.bitfield.recipes.test.core.step.StepSlot; +import eu.bitfield.recipes.test.data.EntitySlots; +import eu.bitfield.recipes.test.data.ToEntitySlots; +import eu.bitfield.recipes.view.recipe.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.List; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.util.To.*; + +public interface RecipeViewQueries extends LinkRecCatQueries, StepQueries, IngredientQueries { + default RecipeViewIn invalid(RecipeViewIn viewIn) { + return viewIn.withRecipe(invalid(viewIn.recipe())); + } + + default RecipeViewGroup recipeViewGroup(RecipeSlot recipe) { + List categories = linkedCategorySlots(recipe).map(to::blank).toList(); + List links = linkRecCatSlots(recipe).map(to::blank).toList(); + List ingredients = ingredientSlots(recipe).map(to::blank).toList(); + List steps = stepSlots(recipe).map(to::blank).toList(); + return new RecipeViewGroup(recipe, categories, links, ingredients, steps); + } + + default RecipeViewGroup recipeViewGroup() { + return recipeViewGroup(recipeSlot().blank()); + } + + default Stream recipeViewGroups() { + return recipeSlots().map(to::blank).map(this::recipeViewGroup); + } + + + @RequiredArgsConstructor @Accessors(fluent = true) + class RecipeViewGroup implements ToEntitySlots, ToRecipe, ToRecipeViewIn, ToRecipeView, ToRecipeViewOut { + private final @Getter RecipeSlot recipe; + private final @Getter List categories; + private final @Getter List links; + private final @Getter List ingredients; + private final @Getter List steps; + private RecipeView recipeView; + private RecipeViewOut recipeViewOut; + private RecipeViewIn recipeViewIn; + + public EntitySlots toEntitySlots() { + return EntitySlots.slot(recipe).add(categories).add(links).add(ingredients).add(steps); + } + + public RecipeView toRecipeView() { + if (recipeView == null) { + Recipe savedRecipe = recipe.saved(); + List savedCategories = categories.stream().map(to::saved).toList(); + List savedLinks = links.stream().map(to::saved).toList(); + List savedIngredients = ingredients.stream().map(to::saved).toList(); + List savedSteps = steps.stream().map(to::saved).toList(); + recipeView = new RecipeView(savedRecipe, savedCategories, savedLinks, savedIngredients, savedSteps); + } + return recipeView; + } + + public RecipeViewOut toRecipeViewOut() { + if (recipeViewOut == null) { + recipeViewOut = toRecipeView().toRecipeViewOut(); + } + return recipeViewOut; + } + + public RecipeViewIn toRecipeViewIn() { + if (recipeViewIn == null) { + RecipeIn recipeIn = recipe.toRecipeIn(); + List categoriesIn = categories.stream().map(toCategoryIn).toList(); + List ingredientsIn = ingredients.stream().map(toIngredientIn).toList(); + List stepsIn = steps.stream().map(toStepIn).toList(); + recipeViewIn = new RecipeViewIn(recipeIn, categoriesIn, ingredientsIn, stepsIn); + } + return recipeViewIn; + } + + public Recipe toRecipe() { + return recipe.toRecipe(); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/view/recipe/ToRecipeView.java b/src/test/java/eu/bitfield/recipes/test/view/recipe/ToRecipeView.java new file mode 100644 index 0000000..f242eca --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/view/recipe/ToRecipeView.java @@ -0,0 +1,7 @@ +package eu.bitfield.recipes.test.view.recipe; + +import eu.bitfield.recipes.view.recipe.RecipeView; + +public interface ToRecipeView { + RecipeView toRecipeView(); +} diff --git a/src/test/java/eu/bitfield/recipes/util/TestUtils.java b/src/test/java/eu/bitfield/recipes/util/TestUtils.java new file mode 100644 index 0000000..d8398ac --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/util/TestUtils.java @@ -0,0 +1,28 @@ +package eu.bitfield.recipes.util; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.*; + +public class TestUtils { + public static Mono noSubscriptionMono() { + return Mono.error(() -> new IllegalStateException("Mono should not have been subscribed")); + } + + public static Flux noSubscriptionFlux() { + return Flux.error(IllegalStateException::new); + } + + public static Consumer> thatAllSatisfy(Consumer checkAssert) { + return checks -> assertThat(checks).allSatisfy(checkAssert); + } + + public static T assertNotNull(T value) { + assertThat(value).isNotNull(); + return value; + } +} diff --git a/src/test/java/eu/bitfield/recipes/util/To.java b/src/test/java/eu/bitfield/recipes/util/To.java new file mode 100644 index 0000000..5cb11b5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/util/To.java @@ -0,0 +1,63 @@ +package eu.bitfield.recipes.util; + +import eu.bitfield.recipes.core.category.CategoryIn; +import eu.bitfield.recipes.core.category.CategoryOut; +import eu.bitfield.recipes.core.category.ToCategoryIn; +import eu.bitfield.recipes.core.category.ToCategoryOut; +import eu.bitfield.recipes.core.ingredient.IngredientIn; +import eu.bitfield.recipes.core.ingredient.IngredientOut; +import eu.bitfield.recipes.core.ingredient.ToIngredientIn; +import eu.bitfield.recipes.core.ingredient.ToIngredientOut; +import eu.bitfield.recipes.core.recipe.*; +import eu.bitfield.recipes.core.step.StepIn; +import eu.bitfield.recipes.core.step.StepOut; +import eu.bitfield.recipes.core.step.ToStepIn; +import eu.bitfield.recipes.core.step.ToStepOut; +import eu.bitfield.recipes.test.core.recipe.ToRecipe; +import eu.bitfield.recipes.test.data.*; +import eu.bitfield.recipes.test.view.recipe.ToRecipeView; +import eu.bitfield.recipes.view.recipe.*; +import reactor.core.publisher.Flux; + +import java.util.function.Function; + +public interface To { + To to = new ToImpl(); + Function toCategoryIn = ToCategoryIn::toCategoryIn; + Function toCategoryOut = ToCategoryOut::toCategoryOut; + Function toStepIn = ToStepIn::toStepIn; + Function toStepOut = ToStepOut::toStepOut; + Function toIngredientIn = ToIngredientIn::toIngredientIn; + Function toIngredientOut = ToIngredientOut::toIngredientOut; + Function toRecipeIn = ToRecipeIn::toRecipeIn; + Function toRecipeOut = ToRecipeOut::toRecipeOut; + Function toRecipeView = ToRecipeView::toRecipeView; + Function toRecipeViewIn = ToRecipeViewIn::toRecipeViewIn; + Function toRecipeViewOut = ToRecipeViewOut::toRecipeViewOut; + Function toSlots = ToEntitySlots::toEntitySlots; + Function toRecipe = ToRecipe::toRecipe; + Function toId = Id::id; + + default > Flux saved(Flux flux) { + return flux.map(Saved::saved); + } + + default > T saved(S value) { + return value.saved(); + } + + default > Flux initial(Flux flux) { + return flux.map(Initial::initial); + } + + default > T initial(I value) { + return value.initial(); + } + + default , B extends SlotBuilder> S blank(B value) { + return value.blank(); + } +} + +record ToImpl() implements To {} + diff --git a/src/test/java/eu/bitfield/recipes/util/Transaction.java b/src/test/java/eu/bitfield/recipes/util/Transaction.java new file mode 100644 index 0000000..cebc02e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/util/Transaction.java @@ -0,0 +1,35 @@ +package eu.bitfield.recipes.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.reactive.TransactionalOperator; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@RequiredArgsConstructor +public class Transaction { + private final TransactionalOperator transactionalOperator; + + Mono withRollback(Mono mono) { + return transactionalOperator.execute(tx -> { + tx.setRollbackOnly(); + return mono; + }) + .next(); + } + + Flux withRollback(Flux flux) { + return transactionalOperator.execute(tx -> { + tx.setRollbackOnly(); + return flux; + }); + } + + public StepVerifier.FirstStep rollbackVerify(Mono mono) { + return StepVerifier.create(withRollback(mono)); + } + + public StepVerifier.FirstStep rollbackVerify(Flux flux) { + return StepVerifier.create(withRollback(flux)); + } +} diff --git a/src/test/resources/clean.sql b/src/test/resources/clean.sql new file mode 100644 index 0000000..cf76fe2 --- /dev/null +++ b/src/test/resources/clean.sql @@ -0,0 +1,27 @@ +DELETE +FROM account; +ALTER SEQUENCE account_id_seq RESTART WITH 1; + +DELETE +FROM category; +ALTER SEQUENCE category_id_seq RESTART WITH 1; + +DELETE +FROM ingredient; +ALTER SEQUENCE ingredient_id_seq RESTART WITH 1; + +DELETE +FROM profile; +ALTER SEQUENCE profile_id_seq RESTART WITH 1; + +DELETE +FROM recipe; +ALTER SEQUENCE recipe_id_seq RESTART WITH 1; + +DELETE +FROM link_rec_cat; +ALTER SEQUENCE link_rec_cat_id_seq RESTART WITH 1; + +DELETE +FROM step; +ALTER SEQUENCE step_id_seq RESTART WITH 1;