feat!: initial prototype
This commit is contained in:
12
src/main/java/eu/bitfield/recipes/RecipesApplication.java
Normal file
12
src/main/java/eu/bitfield/recipes/RecipesApplication.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package eu.bitfield.recipes;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
|
||||
@SpringBootApplication
|
||||
public class RecipesApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RecipesApplication.class, args);
|
||||
}
|
||||
}
|
||||
113
src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java
Normal file
113
src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java
Normal file
@@ -0,0 +1,113 @@
|
||||
package eu.bitfield.recipes.api;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.validation.ObjectError;
|
||||
import org.springframework.validation.method.ParameterValidationResult;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.ErrorResponseException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.support.WebExchangeBindException;
|
||||
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
|
||||
import java.lang.reflect.Executable;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.CollectionUtils.*;
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
public interface ErrorResponseHandling {
|
||||
Logger log = LoggerFactory.getLogger(ErrorResponseHandling.class);
|
||||
|
||||
private static void addMethodParameter(ProblemDetail problemDetail, MethodParameter methodParam) {
|
||||
Executable executable = methodParam.getExecutable();
|
||||
Parameter param = methodParam.getParameter();
|
||||
String method = executable.toGenericString();
|
||||
String argument = param.getType().getCanonicalName() + " " + param.getName();
|
||||
problemDetail.setProperty("method", method);
|
||||
problemDetail.setProperty("argument", argument);
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
default ErrorResponse handleError(HandlerMethodValidationException e) {
|
||||
log.debug("""
|
||||
Handling {}:
|
||||
method: {}
|
||||
errors: {}""",
|
||||
e.getClass().getName(), e.getMethod(), e.getAllErrors(), e);
|
||||
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
|
||||
|
||||
String method = e.getMethod().toGenericString();
|
||||
problemDetail.setProperty("method", method);
|
||||
|
||||
MultiValueMap<String, String> errors = multiValueMap();
|
||||
for (ParameterValidationResult result : e.getParameterValidationResults()) {
|
||||
Parameter param = result.getMethodParameter().getParameter();
|
||||
String errorKey = param.getName();
|
||||
List<String> errorValues = result.getResolvableErrors()
|
||||
.stream()
|
||||
.map(MessageSourceResolvable::getDefaultMessage)
|
||||
.toList();
|
||||
errors.addAll(errorKey, errorValues);
|
||||
}
|
||||
problemDetail.setProperty("errors", errors);
|
||||
|
||||
return ErrorResponse.builder(e, problemDetail).build();
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
default ErrorResponse handleError(WebExchangeBindException e) {
|
||||
List<ObjectError> objectErrors = e.getAllErrors();
|
||||
log.debug("""
|
||||
Handling {}:
|
||||
methodParameter: {}
|
||||
errors: {}""",
|
||||
e.getClass().getName(), e.getMethodParameter(), e.getAllErrors(), e);
|
||||
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
|
||||
|
||||
MethodParameter methodParam = e.getMethodParameter();
|
||||
if (methodParam != null) addMethodParameter(problemDetail, methodParam);
|
||||
|
||||
MultiValueMap<String, String> errors = multiValueMap();
|
||||
for (ObjectError objectError : objectErrors) {
|
||||
String errorKey = objectError.getObjectName();
|
||||
if (objectError instanceof FieldError fieldError) {
|
||||
errorKey += "." + fieldError.getField();
|
||||
}
|
||||
errors.add(errorKey, objectError.getDefaultMessage());
|
||||
}
|
||||
problemDetail.setProperty("errors", errors);
|
||||
|
||||
return ErrorResponse.builder(e, problemDetail).build();
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
default ErrorResponse handleError(ServerWebInputException e) {
|
||||
log.debug("""
|
||||
Handling {}:
|
||||
methodParameter: {}""",
|
||||
e.getClass().getName(), e.getMethodParameter(), e);
|
||||
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
|
||||
|
||||
MethodParameter methodParam = e.getMethodParameter();
|
||||
if (methodParam != null) addMethodParameter(problemDetail, methodParam);
|
||||
|
||||
return ErrorResponse.builder(e, problemDetail).build();
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
default ErrorResponse handleError(ErrorResponseException e) {
|
||||
log.debug("Handling {}", e.getClass().getName(), e);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package eu.bitfield.recipes.api.account;
|
||||
|
||||
import eu.bitfield.recipes.api.ErrorResponseHandling;
|
||||
import eu.bitfield.recipes.core.account.AccountIn;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.ErrorUtils.*;
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController @RequestMapping("/api/account")
|
||||
public class AccountEndpoint implements ErrorResponseHandling {
|
||||
private final RegisterAccount registerOp;
|
||||
|
||||
@PostMapping("/register")
|
||||
public Mono<Void> registerAccount(@RequestBody @Valid AccountIn accountIn) {
|
||||
return registerOp.registerAccount(accountIn)
|
||||
.onErrorMap(RegisterAccount.EmailAlreadyInUse.class,
|
||||
e -> errorResponseException(e, BAD_REQUEST))
|
||||
.then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package eu.bitfield.recipes.api.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.password.Password;
|
||||
import eu.bitfield.recipes.core.account.Account;
|
||||
import eu.bitfield.recipes.core.account.AccountIn;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import eu.bitfield.recipes.core.profile.Profile;
|
||||
import eu.bitfield.recipes.core.profile.ProfileService;
|
||||
import eu.bitfield.recipes.view.registration.RegistrationView;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RegisterAccount {
|
||||
private final ProfileService profileServ;
|
||||
private final AccountService accountServ;
|
||||
|
||||
@Transactional
|
||||
public Mono<RegistrationView> registerAccount(AccountIn accountIn) {
|
||||
return Mono.defer(() -> {
|
||||
return accountServ.checkEmail(EmailAddress.of(accountIn.email()))
|
||||
.onErrorMap(AccountService.EmailAlreadyInUse.class, EmailAlreadyInUse::new);
|
||||
})
|
||||
.zipWhen((EmailAddress __) -> profileServ.addProfile())
|
||||
.flatMap(function((EmailAddress email, Profile profile) -> {
|
||||
return some(accountIn.password())
|
||||
.map(accountServ::encode)
|
||||
.flatMap((Password password) -> accountServ.addAccount(profile.id(), email, password))
|
||||
.map((Account account) -> new RegistrationView(profile, account));
|
||||
}));
|
||||
}
|
||||
|
||||
public static class EmailAlreadyInUse extends Error {
|
||||
public EmailAlreadyInUse(AccountService.EmailAlreadyInUse e) {super(e);}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(Throwable cause) {super("account registration failed", cause);}
|
||||
}
|
||||
}
|
||||
43
src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java
Normal file
43
src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.util.Chronology;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static reactor.core.publisher.Mono.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AddRecipe {
|
||||
private final RecipeService recipeServ;
|
||||
private final CategoryService categoryServ;
|
||||
private final LinkRecCatService linkServ;
|
||||
private final IngredientService ingredientServ;
|
||||
private final StepService stepServ;
|
||||
private final Chronology time;
|
||||
|
||||
|
||||
@Transactional
|
||||
public Mono<RecipeView> addRecipe(RecipeViewIn detailsIn, long profileId) {
|
||||
return defer(() -> recipeServ.addRecipe(profileId, detailsIn.recipe(), time.now()))
|
||||
.zipWith(categoryServ.addCategories(detailsIn.categories()))
|
||||
.flatMap(function((recipe, category) -> {
|
||||
return zip(linkServ.addLinks(recipe.id(), category),
|
||||
ingredientServ.addIngredients(recipe.id(), detailsIn.ingredients()),
|
||||
stepServ.addSteps(recipe.id(), detailsIn.steps()))
|
||||
.map(function((links, ingredients, steps) -> {
|
||||
return new RecipeView(recipe, category, links, ingredients, steps);
|
||||
}));
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FindRecipes {
|
||||
private final RecipeService recipeServ;
|
||||
private final GetRecipe getRecipe;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Mono<List<RecipeViewOut>> findRecipes(
|
||||
@Nullable String categoryName,
|
||||
@Nullable String recipeName,
|
||||
Pagination pagination)
|
||||
{
|
||||
return recipeServ.findRecipeIds(categoryName, recipeName, pagination)
|
||||
.flatMapSequential(getRecipe::getRecipe)
|
||||
.collectList(); // collect to close transaction on completion
|
||||
}
|
||||
}
|
||||
48
src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java
Normal file
48
src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.view.recipe.RecipeView.*;
|
||||
import static reactor.core.publisher.Mono.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GetRecipe {
|
||||
private final RecipeService recipeServ;
|
||||
private final CategoryService categoryServ;
|
||||
private final IngredientService ingredientServ;
|
||||
private final StepService stepServ;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Mono<RecipeViewOut> getRecipe(long recipeId) {
|
||||
return defer(() -> recipeServ.getRecipe(recipeId).onErrorMap(RecipeNotFound.class, NotFound::new))
|
||||
.flatMap(recipe -> {
|
||||
return zip(categoryServ.getCategories(recipeId),
|
||||
ingredientServ.getIngredients(recipeId),
|
||||
stepServ.getSteps(recipeId))
|
||||
.map(function((categories, ingredients, steps) -> {
|
||||
return createRecipeViewOut(recipe, categories, ingredients, steps);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
public static class NotFound extends Error {
|
||||
public NotFound(RecipeNotFound recipeNotFound) {super(recipeNotFound);}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(Throwable cause) {super("recipe details retrieval failed", cause);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.api.ErrorResponseHandling;
|
||||
import eu.bitfield.recipes.auth.ProfileIdentityAccess;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.ErrorUtils.*;
|
||||
import static java.util.function.Function.*;
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController @RequestMapping("/api/recipe")
|
||||
public class RecipeEndpoint implements ErrorResponseHandling {
|
||||
public static final long MIN_LIMIT = 1;
|
||||
public static final long MAX_LIMIT = 100;
|
||||
private final AddRecipe addOp;
|
||||
private final GetRecipe getOp;
|
||||
private final UpdateRecipe updateOp;
|
||||
private final RemoveRecipe removeOp;
|
||||
private final FindRecipes findOp;
|
||||
private final ProfileIdentityAccess profile;
|
||||
|
||||
@PostMapping("/new")
|
||||
public Mono<RecipeViewOut> addRecipe(@RequestBody @Valid RecipeViewIn recipeViewIn) {
|
||||
return profile.id()
|
||||
.flatMap(profileId -> addOp.addRecipe(recipeViewIn, profileId))
|
||||
.map(RecipeView::toRecipeViewOut);
|
||||
}
|
||||
|
||||
@GetMapping("/{recipeId}")
|
||||
public Mono<RecipeViewOut> getRecipe(@PathVariable long recipeId) {
|
||||
return getOp.getRecipe(recipeId)
|
||||
.onErrorMap(GetRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND));
|
||||
}
|
||||
|
||||
@PutMapping("/{recipeId}")
|
||||
public Mono<RecipeViewOut> updateRecipe(@PathVariable long recipeId,
|
||||
@RequestBody @Valid RecipeViewIn recipeViewIn)
|
||||
{
|
||||
return profile.id()
|
||||
.flatMap(profileId -> updateOp.updateRecipe(recipeId, recipeViewIn, profileId))
|
||||
.onErrorMap(UpdateRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND))
|
||||
.onErrorMap(UpdateRecipe.Forbidden.class, e -> errorResponseException(e, FORBIDDEN))
|
||||
.map(RecipeView::toRecipeViewOut);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{recipeId}")
|
||||
public Mono<Void> removeRecipe(@PathVariable long recipeId) {
|
||||
return profile.id()
|
||||
.flatMap(profileId -> removeOp.removeRecipe(recipeId, profileId))
|
||||
.onErrorMap(RemoveRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND))
|
||||
.onErrorMap(RemoveRecipe.Forbidden.class, e -> errorResponseException(e, FORBIDDEN))
|
||||
.then();
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public Flux<RecipeViewOut> findRecipes(
|
||||
@RequestParam(name = "category", required = false) @Nullable String categoryName,
|
||||
@RequestParam(name = "recipe", required = false) @Nullable String recipeName,
|
||||
@RequestParam(name = "limit") @Min(MIN_LIMIT) @Max(MAX_LIMIT) @Valid long limit,
|
||||
@RequestParam(name = "offset") @PositiveOrZero @Valid long offset)
|
||||
{
|
||||
return defer(() -> findOp.findRecipes(categoryName, recipeName, new Pagination(limit, offset)))
|
||||
.flatMapIterable(identity());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class RemoveRecipe {
|
||||
private final RecipeService recipeService;
|
||||
|
||||
@Transactional
|
||||
public Mono<Boolean> removeRecipe(long recipeId, long profileId) {
|
||||
return recipeService.removeRecipe(recipeId, profileId)
|
||||
.onErrorMap(RecipeNotFound.class, NotFound::new)
|
||||
.onErrorMap(RemoveRecipeForbidden.class, Forbidden::new);
|
||||
}
|
||||
|
||||
public static final class NotFound extends Error {
|
||||
public NotFound(RecipeNotFound recipeNotFound) {super(recipeNotFound);}
|
||||
}
|
||||
|
||||
public static class Forbidden extends Error {
|
||||
public Forbidden(RemoveRecipeForbidden forbidden) {super(forbidden);}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(Throwable cause) {super("recipe details removal failed", cause);}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.util.Chronology;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static reactor.core.publisher.Mono.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UpdateRecipe {
|
||||
private final RecipeService recipeServ;
|
||||
private final CategoryService categoryServ;
|
||||
private final LinkRecCatService linkServ;
|
||||
private final StepService stepServ;
|
||||
private final IngredientService ingredientServ;
|
||||
private final Chronology time;
|
||||
|
||||
@Transactional
|
||||
public Mono<RecipeView> updateRecipe(long recipeId, RecipeViewIn detailsIn, long profileId) {
|
||||
return Mono.defer(() -> {
|
||||
return recipeServ.updateRecipe(recipeId, profileId, detailsIn.recipe(), time.now())
|
||||
.onErrorMap(RecipeNotFound.class, NotFound::new)
|
||||
.onErrorMap(UpdateRecipeForbidden.class, Forbidden::new);
|
||||
})
|
||||
.zipWhen(__ -> categoryServ.addCategories(detailsIn.categories()))
|
||||
.flatMap(function((recipe, categories) -> {
|
||||
return zip(linkServ.updateLinks(recipeId, categories),
|
||||
stepServ.updateSteps(recipeId, detailsIn.steps()),
|
||||
ingredientServ.updateIngredients(recipeId, detailsIn.ingredients()))
|
||||
.map(function((links, steps, ingredients) -> {
|
||||
return new RecipeView(recipe, categories, links, ingredients, steps);
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
public static class NotFound extends Error {
|
||||
public NotFound(RecipeNotFound recipeNotFound) {
|
||||
super(recipeNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Forbidden extends Error {
|
||||
public Forbidden(UpdateRecipeForbidden forbidden) {
|
||||
super(forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(Throwable cause) {
|
||||
super("recipe details update failed", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java
Normal file
18
src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
import eu.bitfield.recipes.core.account.Account;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter @Accessors(fluent = true)
|
||||
public class AccountPrincipal extends User implements ProfileIdentity {
|
||||
private final long profileId;
|
||||
|
||||
public AccountPrincipal(Account account) {
|
||||
super(account.email(), account.passwordEncoded(), List.of());
|
||||
this.profileId = account.profileId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.email.EmailAddressIn;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @Transactional(readOnly = true)
|
||||
public class AccountPrincipalService implements ReactiveUserDetailsService {
|
||||
private final AccountService accountServ;
|
||||
|
||||
@Override
|
||||
public Mono<UserDetails> findByUsername(String address) {
|
||||
return some(address)
|
||||
.mapNotNull(EmailAddressIn::of)
|
||||
.map(EmailAddress::of)
|
||||
.flatMap(accountServ::getAccount)
|
||||
.map(AccountPrincipal::new);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
public interface ProfileIdentity {
|
||||
long profileId();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
public class ProfileIdentityAccess {
|
||||
public Mono<Long> id() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getPrincipal)
|
||||
.cast(ProfileIdentity.class)
|
||||
.map(ProfileIdentity::profileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.bitfield.recipes.auth.email;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
import static java.util.Comparator.*;
|
||||
|
||||
@Getter @Accessors(fluent = true) @EqualsAndHashCode
|
||||
public class EmailAddress implements Comparable<EmailAddress> {
|
||||
public static Comparator<EmailAddress> order = comparing(EmailAddress::address);
|
||||
private final String address;
|
||||
|
||||
private EmailAddress(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public static EmailAddress of(EmailAddressIn emailIn) {
|
||||
return new EmailAddress(emailIn.address().toLowerCase());
|
||||
}
|
||||
|
||||
public int compareTo(@Nullable EmailAddress other) {
|
||||
return order.compare(this, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package eu.bitfield.recipes.auth.email;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static java.util.Comparator.*;
|
||||
|
||||
@Getter @Accessors(fluent = true) @EqualsAndHashCode
|
||||
public class EmailAddressIn implements Comparable<EmailAddressIn> {
|
||||
private static final String EMAIL_REGEX =
|
||||
"^[\\p{Alnum}!#$%&'*+/=?^_`{|}~-]+(?:\\.[\\p{Alnum}!#$%&'*+/=?^_`{|}~-]+)*@" +
|
||||
"\\p{Alnum}(?:[\\p{Alnum}-]{0,61}\\p{Alnum})?(?:\\.\\p{Alnum}(?:[\\p{Alnum}-]{0,61}\\p{Alnum})?)+$";
|
||||
private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);
|
||||
// based on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation/test
|
||||
public static Comparator<EmailAddressIn> order = comparing(EmailAddressIn::address);
|
||||
private final @JsonValue @NotNull @Email(regexp = EMAIL_REGEX) String address;
|
||||
|
||||
@JsonCreator
|
||||
private EmailAddressIn(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public static @Nullable EmailAddressIn of(String address) {
|
||||
if (address == null || !EMAIL_PATTERN.matcher(address).matches()) return null;
|
||||
return new EmailAddressIn(address);
|
||||
}
|
||||
|
||||
public static EmailAddressIn ofUnchecked(String address) {
|
||||
return new EmailAddressIn(address);
|
||||
}
|
||||
|
||||
public int compareTo(@Nullable EmailAddressIn other) {
|
||||
return order.compare(this, other);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.bitfield.recipes.auth.password;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
import static java.util.Comparator.*;
|
||||
|
||||
@Getter @Accessors(fluent = true) @EqualsAndHashCode
|
||||
public class Password implements Comparable<Password> {
|
||||
public static Comparator<Password> order = comparing(Password::encoded);
|
||||
private final String encoded;
|
||||
|
||||
private Password(String encoded) {
|
||||
this.encoded = encoded;
|
||||
}
|
||||
|
||||
public static Password of(Encoder encoder, PasswordIn passwordIn) {
|
||||
return new Password(encoder.encode(passwordIn));
|
||||
}
|
||||
|
||||
public static Password ofUnchecked(String encoded) {
|
||||
return new Password(encoded);
|
||||
}
|
||||
|
||||
public int compareTo(@Nullable Password other) {
|
||||
return order.compare(this, other);
|
||||
}
|
||||
|
||||
public interface Encoder {
|
||||
String encode(PasswordIn password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package eu.bitfield.recipes.auth.password;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
import static java.util.Comparator.*;
|
||||
|
||||
@Getter @Accessors(fluent = true) @EqualsAndHashCode
|
||||
public class PasswordIn implements Comparable<PasswordIn> {
|
||||
public static final int MIN_PASSWORD_LENGTH = 8;
|
||||
public static Comparator<PasswordIn> order = comparing(PasswordIn::raw);
|
||||
private final @JsonValue @NotNull @Size(min = MIN_PASSWORD_LENGTH) String raw;
|
||||
|
||||
@JsonCreator
|
||||
private PasswordIn(String raw) {
|
||||
this.raw = raw;
|
||||
}
|
||||
|
||||
public static PasswordIn ofUnchecked(String raw) {
|
||||
return new PasswordIn(raw);
|
||||
}
|
||||
|
||||
public int compareTo(@Nullable PasswordIn other) {
|
||||
return order.compare(this, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.bitfield.recipes.config;
|
||||
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.concurrent.ConcurrentMapCache;
|
||||
import org.springframework.cache.support.SimpleCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
public class CacheConfiguration {
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
SimpleCacheManager cacheManager = new SimpleCacheManager();
|
||||
cacheManager.setCaches(caches());
|
||||
return cacheManager;
|
||||
}
|
||||
|
||||
List<Cache> caches() {
|
||||
return Stream.of("account")
|
||||
.map(ConcurrentMapCache::new)
|
||||
.map(Cache.class::cast)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eu.bitfield.recipes.config;
|
||||
|
||||
import io.r2dbc.spi.Option;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.r2dbc.ConnectionFactoryOptionsBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class DatabaseConfiguration {
|
||||
@Bean
|
||||
public ConnectionFactoryOptionsBuilderCustomizer connectionFactoryOptionsBuilderCustomizer() {
|
||||
return builder -> {
|
||||
// builder.option(Option.valueOf("timeZone"), TimeZone.getTimeZone(ZoneOffset.UTC));
|
||||
builder.option(Option.valueOf("autodetectExtensions"), false);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package eu.bitfield.recipes.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
|
||||
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
|
||||
|
||||
import static org.springframework.http.HttpMethod.*;
|
||||
|
||||
@Configuration
|
||||
@EnableWebFluxSecurity
|
||||
public class SecurityConfiguration {
|
||||
private SecurityWebFilterChain filterChainDefaults(ServerHttpSecurity http) {
|
||||
// disable session management
|
||||
// https://github.com/spring-projects/spring-security/issues/6552#issuecomment-519398510
|
||||
return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE) @Bean
|
||||
public SecurityWebFilterChain accountEndpointFilterChain(ServerHttpSecurity httpSecurity) {
|
||||
httpSecurity.securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/account/**"))
|
||||
.authorizeExchange(exchange -> exchange.anyExchange().permitAll());
|
||||
return filterChainDefaults(httpSecurity);
|
||||
}
|
||||
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE) @Bean
|
||||
public SecurityWebFilterChain recipeEndpointFilterChain(ServerHttpSecurity httpSecurity) {
|
||||
httpSecurity.securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/recipe/**"))
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.authorizeExchange(exchange -> {
|
||||
exchange.pathMatchers(GET, "/api/recipe/{recipeId:\\d+}").permitAll()
|
||||
.pathMatchers(GET, "/api/recipe/search").permitAll()
|
||||
.anyExchange().authenticated();
|
||||
});
|
||||
return filterChainDefaults(httpSecurity);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityWebFilterChain fallbackFilterChain(ServerHttpSecurity httpSecurity) {
|
||||
httpSecurity.authorizeExchange(exchange -> exchange.anyExchange().denyAll());
|
||||
return filterChainDefaults(httpSecurity);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
||||
// accessed at 2025-04-22
|
||||
int saltLength = 16;
|
||||
int hashLength = 32;
|
||||
int parallelism = 1;
|
||||
int memory = 1 << 16; // in KiB = 64 MiB
|
||||
int iterations = 2;
|
||||
return new Argon2PasswordEncoder(saltLength, hashLength, parallelism, memory, iterations);
|
||||
}
|
||||
}
|
||||
21
src/main/java/eu/bitfield/recipes/core/account/Account.java
Normal file
21
src/main/java/eu/bitfield/recipes/core/account/Account.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.password.Password;
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
|
||||
@With @FieldNameConstants
|
||||
public record Account(
|
||||
@Id long id,
|
||||
long profileId,
|
||||
String email,
|
||||
@Column("password") String passwordEncoded
|
||||
) implements Entity {
|
||||
public static Account initial(long profileId, EmailAddress email, Password password) {
|
||||
return new Account(0, profileId, email.address(), password.encoded());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddressIn;
|
||||
import eu.bitfield.recipes.auth.password.PasswordIn;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.With;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
@With
|
||||
@Validated
|
||||
public record AccountIn(
|
||||
@NotNull @Valid EmailAddressIn email,
|
||||
@NotNull @Valid PasswordIn password
|
||||
) implements ToAccountIn {
|
||||
public AccountIn toAccountIn() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
interface AccountRepository extends ReactiveCrudRepository<Account, Long> {
|
||||
default Mono<Account> addAccount(Account account) {
|
||||
return save(account);
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select * from account
|
||||
where email = $1
|
||||
""")
|
||||
Mono<Account> query_accountByEmail(String emailAddress);
|
||||
|
||||
default Mono<Account> accountByEmail(EmailAddress email) {
|
||||
return query_accountByEmail(email.address());
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select exists(
|
||||
select * from account
|
||||
where email = $1
|
||||
)
|
||||
""")
|
||||
Mono<Boolean> query_isEmailUsed(String emailAddress);
|
||||
|
||||
|
||||
default Mono<Boolean> isEmailUsed(EmailAddress email) {
|
||||
return query_isEmailUsed(email.address());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.password.Password;
|
||||
import eu.bitfield.recipes.auth.password.PasswordIn;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheConfig;
|
||||
import org.springframework.cache.annotation.CachePut;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @CacheConfig(cacheNames = "account")
|
||||
public class AccountService {
|
||||
private final AccountRepository repo;
|
||||
private final PasswordEncoder encoder;
|
||||
|
||||
@Transactional @CachePut(key = "email")
|
||||
public Mono<Account> addAccount(long profileId, EmailAddress email, Password password) {
|
||||
return supply(() -> Account.initial(profileId, email, password)).flatMap(repo::addAccount);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true) @Cacheable
|
||||
public Mono<Account> getAccount(EmailAddress email) {
|
||||
return repo.accountByEmail(email);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Mono<EmailAddress> checkEmail(EmailAddress email) {
|
||||
return repo.isEmailUsed(email).as(errIfTrue(() -> new EmailAlreadyInUse(email))).thenReturn(email);
|
||||
}
|
||||
|
||||
public Password encode(PasswordIn passwordIn) {
|
||||
return Password.of((PasswordIn __) -> encoder.encode(passwordIn.raw()), passwordIn);
|
||||
}
|
||||
|
||||
public static class EmailAlreadyInUse extends Error {
|
||||
public EmailAlreadyInUse(EmailAddress email) {
|
||||
super("address address '" + email.address() + "' is already in use");
|
||||
}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(String error) {super(error);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
public interface ToAccountIn {
|
||||
AccountIn toAccountIn();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Category(
|
||||
@Id long id,
|
||||
String name
|
||||
) implements Entity, ToCategoryOut {
|
||||
public static Category initial(String name) {
|
||||
return new Category(0, name);
|
||||
}
|
||||
|
||||
public CategoryOut toCategoryOut() {
|
||||
return new CategoryOut(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CategoryIn(@JsonValue @NotBlank String name) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public record CategoryOut(@JsonValue String name) implements ToCategoryOut {
|
||||
public CategoryOut toCategoryOut() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface CategoryRepository extends ReactiveCrudRepository<Category, Long> {
|
||||
default Mono<Category> addCategory(Category category) {
|
||||
return save(category);
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select * from category as c
|
||||
inner join link_rec_cat as rc on c.id = rc.category_id
|
||||
where rc.recipe_id = $1
|
||||
""")
|
||||
Flux<Category> categoriesByRecipeId(long recipeId);
|
||||
|
||||
@Query("""
|
||||
select id from category
|
||||
where name = $1
|
||||
""")
|
||||
Mono<Long> categoryIdByName(String name);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @Transactional(readOnly = true)
|
||||
public class CategoryService {
|
||||
private final CategoryRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<List<Category>> addCategories(List<CategoryIn> categoriesIn) {
|
||||
return flux(categoriesIn).flatMapSequential(this::addCategoryIfAbsent).collectList();
|
||||
}
|
||||
|
||||
public Mono<List<Category>> getCategories(long recipeId) {
|
||||
return repo.categoriesByRecipeId(recipeId).collectList();
|
||||
}
|
||||
|
||||
private Mono<Category> addCategoryIfAbsent(CategoryIn categoryIn) {
|
||||
return repo.categoryIdByName(categoryIn.name())
|
||||
.map(id -> new Category(id, categoryIn.name()))
|
||||
.switchIfEmpty(supply(() -> Category.initial(categoryIn.name())).flatMap(repo::addCategory));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
public interface ToCategoryIn {
|
||||
CategoryIn toCategoryIn();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
public interface ToCategoryOut {
|
||||
CategoryOut toCategoryOut();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Ingredient(
|
||||
@Id long id,
|
||||
long recipeId,
|
||||
String name
|
||||
) implements Entity, ToIngredientOut {
|
||||
public static Ingredient initial(long recipeId, String name) {return new Ingredient(0, recipeId, name);}
|
||||
|
||||
public IngredientOut toIngredientOut() {
|
||||
return new IngredientOut(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record IngredientIn(@JsonValue @NotBlank String name) {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public record IngredientOut(@JsonValue String name) {}
|
||||
@@ -0,0 +1,93 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import io.r2dbc.spi.Result;
|
||||
import io.r2dbc.spi.Row;
|
||||
import io.r2dbc.spi.RowMetadata;
|
||||
import io.r2dbc.spi.Statement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.r2dbc.connection.R2dbcTransactionManager;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Spliterator;
|
||||
|
||||
interface IngredientBatchOperations {
|
||||
Flux<Ingredient> addIngredientsBatch(Flux<Ingredient> ingredients);
|
||||
}
|
||||
|
||||
public interface IngredientRepository extends ReactiveCrudRepository<Ingredient, Long>, IngredientBatchOperations {
|
||||
// no batch support as of 2025-05-05
|
||||
// https://github.com/spring-projects/spring-data-r2dbc/issues/259
|
||||
// https://github.com/spring-projects/spring-framework/issues/33812
|
||||
default Flux<Ingredient> addIngredients(List<Ingredient> ingredients) {
|
||||
return saveAll(ingredients);
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select * from ingredient
|
||||
where recipe_id = $1
|
||||
""")
|
||||
Flux<Ingredient> ingredientsByRecipeId(long recipeId);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from ingredient where recipe_id = $1
|
||||
""")
|
||||
Mono<Long> removeIngredientsFromRecipe(long recipeId);
|
||||
}
|
||||
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
class IngredientBatchOperationsImpl implements IngredientBatchOperations {
|
||||
private final DatabaseClient client;
|
||||
private final R2dbcTransactionManager transactionManager;
|
||||
private final String addBatchSql =
|
||||
"""
|
||||
insert into ingredient(recipe_id, name) values ($1, $2)
|
||||
""";
|
||||
private final String idColumn = Ingredient.Fields.id;
|
||||
|
||||
@Override
|
||||
public Flux<Ingredient> addIngredientsBatch(Flux<Ingredient> ingredients) {
|
||||
return ingredients.collectList()
|
||||
.flatMapMany(this::addIngredientsBatch);
|
||||
}
|
||||
|
||||
public Flux<Ingredient> addIngredientsBatch(List<Ingredient> ingredients) {
|
||||
if (ingredients.isEmpty()) {
|
||||
return Flux.empty();
|
||||
}
|
||||
return client.inConnectionMany(connection -> {
|
||||
Statement statement = connection.createStatement(addBatchSql);
|
||||
statement.returnGeneratedValues(idColumn);
|
||||
Spliterator<Ingredient> iter = ingredients.spliterator();
|
||||
iter.tryAdvance(ingredient -> bind(statement, ingredient));
|
||||
iter.forEachRemaining(ingredient -> bind(statement.add(), ingredient));
|
||||
var results = Flux.from(statement.execute());
|
||||
return Flux.fromIterable(ingredients)
|
||||
.zipWith(results.concatMap(this::idFromResult), Ingredient::withId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Publisher<Long> idFromResult(Result result) {
|
||||
return result.map((Row row, RowMetadata rowMetadata) -> idFromRow(row));
|
||||
}
|
||||
|
||||
Long idFromRow(Row row) {
|
||||
Long id = row.get(idColumn, Long.class);
|
||||
return id;
|
||||
}
|
||||
|
||||
void bind(Statement statement, Ingredient ingredient) {
|
||||
statement.bind(0, ingredient.recipeId())
|
||||
.bind(1, ingredient.name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class IngredientService {
|
||||
private final IngredientRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<List<Ingredient>> addIngredients(long recipeId, List<IngredientIn> ingredientsIn) {
|
||||
return flux(ingredientsIn)
|
||||
.map(ingredientIn -> Ingredient.initial(recipeId, ingredientIn.name()))
|
||||
.collectList()
|
||||
.flatMapMany(repo::addIngredients)
|
||||
.collectList();
|
||||
}
|
||||
|
||||
public Mono<List<Ingredient>> getIngredients(long recipeId) {
|
||||
return repo.ingredientsByRecipeId(recipeId).collectList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<List<Ingredient>> updateIngredients(long recipeId, List<IngredientIn> ingredients) {
|
||||
return repo.removeIngredientsFromRecipe(recipeId).then(addIngredients(recipeId, ingredients));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
|
||||
public interface ToIngredientIn {
|
||||
IngredientIn toIngredientIn();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.core.ingredient;
|
||||
|
||||
public interface ToIngredientOut {
|
||||
IngredientOut toIngredientOut();
|
||||
}
|
||||
|
||||
20
src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java
Normal file
20
src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package eu.bitfield.recipes.core.link;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
/// recipe category link
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record LinkRecCat(
|
||||
@Id long id,
|
||||
long recipeId,
|
||||
long categoryId
|
||||
) implements Entity {
|
||||
public static LinkRecCat initial(long recipeId, long categoryId) {
|
||||
return new LinkRecCat(0, recipeId, categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package eu.bitfield.recipes.core.link;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface LinkRecCatRepository extends ReactiveCrudRepository<LinkRecCat, Long> {
|
||||
default Flux<LinkRecCat> addLinks(List<LinkRecCat> links) {
|
||||
return saveAll(links);
|
||||
}
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from link_rec_cat where recipe_id = $1
|
||||
""")
|
||||
Mono<Long> removeCategoriesFromRecipe(long recipeId);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package eu.bitfield.recipes.core.link;
|
||||
|
||||
import eu.bitfield.recipes.core.category.Category;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class LinkRecCatService {
|
||||
private final LinkRecCatRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<List<LinkRecCat>> addLinks(long recipeId, List<Category> categories) {
|
||||
return flux(categories)
|
||||
.map(category -> LinkRecCat.initial(recipeId, category.id()))
|
||||
.collectList()
|
||||
.flatMapMany(repo::addLinks)
|
||||
.collectList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<List<LinkRecCat>> updateLinks(long recipeId, List<Category> categories) {
|
||||
return repo.removeCategoriesFromRecipe(recipeId).then(addLinks(recipeId, categories));
|
||||
}
|
||||
}
|
||||
14
src/main/java/eu/bitfield/recipes/core/profile/Profile.java
Normal file
14
src/main/java/eu/bitfield/recipes/core/profile/Profile.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package eu.bitfield.recipes.core.profile;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Profile(@Id long id) implements Entity {
|
||||
public static Profile initial() {return new Profile(0);}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package eu.bitfield.recipes.core.profile;
|
||||
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface ProfileRepository extends ReactiveCrudRepository<Profile, Long> {
|
||||
default Mono<Profile> addProfile(Profile initialProfile) {
|
||||
return save(initialProfile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.bitfield.recipes.core.profile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @Transactional(readOnly = true)
|
||||
public class ProfileService {
|
||||
private final ProfileRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<Profile> addProfile() {return supply(Profile::initial).flatMap(repo::addProfile);}
|
||||
}
|
||||
27
src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java
Normal file
27
src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Recipe(
|
||||
@Id long id,
|
||||
long authorProfileId,
|
||||
String name,
|
||||
String description,
|
||||
Instant changedAt
|
||||
) implements Entity, ToRecipeOut {
|
||||
public static Recipe initial(long authorProfileId, String name, String description, Instant changedAt) {
|
||||
return new Recipe(0, authorProfileId, name, description, changedAt);
|
||||
}
|
||||
|
||||
public RecipeOut toRecipeOut() {
|
||||
return new RecipeOut(id, name, description, changedAt);
|
||||
}
|
||||
}
|
||||
|
||||
11
src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java
Normal file
11
src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.With;
|
||||
|
||||
@With
|
||||
public record RecipeIn(
|
||||
@NotBlank String name,
|
||||
@NotBlank String description
|
||||
) {
|
||||
}
|
||||
13
src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java
Normal file
13
src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import lombok.With;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@With
|
||||
public record RecipeOut(
|
||||
long id,
|
||||
String name,
|
||||
String description,
|
||||
Instant changedAt
|
||||
) {}
|
||||
@@ -0,0 +1,61 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import org.springframework.lang.Nullable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public interface RecipeRepository extends ReactiveCrudRepository<Recipe, Long> {
|
||||
default Mono<Recipe> addRecipe(Recipe recipe) {
|
||||
return save(recipe);
|
||||
}
|
||||
|
||||
default Mono<Recipe> recipe(long recipeId) {
|
||||
return findById(recipeId);
|
||||
}
|
||||
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
update recipe
|
||||
set name = $2,
|
||||
description = $3,
|
||||
changed_at = $4
|
||||
where id = $1
|
||||
""")
|
||||
Mono<Boolean> updateRecipe(long recipeId, String name, String description, Instant changedAt);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from recipe
|
||||
where id = $1
|
||||
""")
|
||||
Mono<Boolean> removeRecipe(long recipeId);
|
||||
|
||||
@Query("""
|
||||
select id from recipe as r
|
||||
where
|
||||
($2 is null or position(lower($2) in lower(r.name)) > 0) and
|
||||
($1 is null or
|
||||
exists(
|
||||
select * from category as c
|
||||
inner join link_rec_cat as rc on c.id = rc.category_id
|
||||
where lower(c.name) = lower($1) and rc.recipe_id = r.id
|
||||
)
|
||||
)
|
||||
order by r.changed_at desc
|
||||
limit $3
|
||||
offset $4
|
||||
""")
|
||||
Flux<Long> recipeIds(@Nullable String categoryName, @Nullable String recipeName, long limit, long offset);
|
||||
|
||||
@Query("""
|
||||
select author_profile_id = $2 from recipe
|
||||
where id = $1
|
||||
""")
|
||||
Mono<Boolean> canEditRecipe(long recipeId, long profileId);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.NestedRuntimeException;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Service @Transactional(readOnly = true)
|
||||
public class RecipeService {
|
||||
private final RecipeRepository repo;
|
||||
|
||||
@Transactional
|
||||
public Mono<Recipe> addRecipe(long authorProfileId, RecipeIn recipeIn, Instant createdAt) {
|
||||
return repo.addRecipe(Recipe.initial(authorProfileId, recipeIn.name(), recipeIn.description(), createdAt));
|
||||
}
|
||||
|
||||
public Mono<Recipe> getRecipe(long recipeId) {
|
||||
return repo.recipe(recipeId).as(checkEmpty(recipeId));
|
||||
}
|
||||
|
||||
public Flux<Long> findRecipeIds(@Nullable String categoryName, @Nullable String recipeName, Pagination pagination) {
|
||||
return repo.recipeIds(categoryName, recipeName, pagination.limit(), pagination.offset());
|
||||
}
|
||||
|
||||
public Mono<Recipe> updateRecipe(long recipeId, long profileId, RecipeIn recipeIn, Instant changedAt) {
|
||||
return checkEdit(recipeId, profileId, () -> new UpdateRecipeForbidden(recipeId))
|
||||
.then(repo.updateRecipe(recipeId, recipeIn.name(), recipeIn.description(), changedAt))
|
||||
.then(supply(() -> new Recipe(recipeId, profileId, recipeIn.name(), recipeIn.description(), changedAt)));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Boolean> removeRecipe(long recipeId, long profileId) {
|
||||
return checkEdit(recipeId, profileId, () -> new RemoveRecipeForbidden(recipeId))
|
||||
.then(repo.removeRecipe(recipeId));
|
||||
}
|
||||
|
||||
private <T> UnaryOperator<Mono<T>> checkEmpty(long recipeId) {
|
||||
return (Mono<T> item) -> item.as(errIfEmpty(() -> new RecipeNotFound(recipeId)));
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkEdit(long recipeId, long profileId, Supplier<? extends Throwable> forbiddenError) {
|
||||
return repo.canEditRecipe(recipeId, profileId)
|
||||
.as(checkEmpty(recipeId))
|
||||
.as(errIfFalse(forbiddenError));
|
||||
}
|
||||
|
||||
public static class RecipeNotFound extends Error {
|
||||
public RecipeNotFound(long recipeId) {super("no such recipe (id=" + recipeId + ")");}
|
||||
}
|
||||
|
||||
public static class UpdateRecipeForbidden extends Error {
|
||||
public UpdateRecipeForbidden(long recipeId) {super("updating recipe (id=" + recipeId + ") is not allowed");}
|
||||
}
|
||||
|
||||
public static class RemoveRecipeForbidden extends Error {
|
||||
public RemoveRecipeForbidden(long recipeId) {super("removing recipe (id=" + recipeId + ") is not allowed");}
|
||||
}
|
||||
|
||||
public static class Error extends NestedRuntimeException {
|
||||
public Error(String error) {super(error);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
public interface ToRecipeIn {
|
||||
RecipeIn toRecipeIn();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.recipe;
|
||||
|
||||
public interface ToRecipeOut {
|
||||
RecipeOut toRecipeOut();
|
||||
}
|
||||
21
src/main/java/eu/bitfield/recipes/core/step/Step.java
Normal file
21
src/main/java/eu/bitfield/recipes/core/step/Step.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import eu.bitfield.recipes.util.Entity;
|
||||
import lombok.With;
|
||||
import lombok.experimental.FieldNameConstants;
|
||||
import org.springframework.data.annotation.Id;
|
||||
|
||||
@With
|
||||
@FieldNameConstants
|
||||
public record Step(
|
||||
@Id long id,
|
||||
long recipeId,
|
||||
String name
|
||||
) implements Entity, ToStepOut {
|
||||
public static Step initial(long recipeId, String name) {return new Step(0, recipeId, name);}
|
||||
|
||||
public StepOut toStepOut() {
|
||||
return new StepOut(name);
|
||||
}
|
||||
}
|
||||
|
||||
7
src/main/java/eu/bitfield/recipes/core/step/StepIn.java
Normal file
7
src/main/java/eu/bitfield/recipes/core/step/StepIn.java
Normal file
@@ -0,0 +1,7 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record StepIn(@JsonValue @NotBlank String name) {
|
||||
}
|
||||
5
src/main/java/eu/bitfield/recipes/core/step/StepOut.java
Normal file
5
src/main/java/eu/bitfield/recipes/core/step/StepOut.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public record StepOut(@JsonValue String name) {}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface StepRepository extends ReactiveCrudRepository<Step, Long> {
|
||||
// no batch support as of 2025-05-05
|
||||
// https://github.com/spring-projects/spring-data-r2dbc/issues/259
|
||||
// https://github.com/spring-projects/spring-framework/issues/33812
|
||||
default Flux<Step> addSteps(List<Step> steps) {
|
||||
return saveAll(steps);
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select * from step
|
||||
where recipe_id = $1
|
||||
order by id;
|
||||
""")
|
||||
Flux<Step> stepsByRecipeId(long recipeId);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
delete from step where recipe_id = $1
|
||||
""")
|
||||
Mono<Long> removeStepsFromRecipe(long recipeId);
|
||||
}
|
||||
32
src/main/java/eu/bitfield/recipes/core/step/StepService.java
Normal file
32
src/main/java/eu/bitfield/recipes/core/step/StepService.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class StepService {
|
||||
private final StepRepository repo;
|
||||
|
||||
public Mono<List<Step>> addSteps(long recipeId, List<StepIn> stepsIn) {
|
||||
return flux(stepsIn).map(stepIn -> Step.initial(recipeId, stepIn.name()))
|
||||
.collectList()
|
||||
.flatMapMany(repo::addSteps)
|
||||
.collectList();
|
||||
}
|
||||
|
||||
public Mono<List<Step>> getSteps(long recipeId) {
|
||||
return repo.stepsByRecipeId(recipeId).collectList();
|
||||
}
|
||||
|
||||
public Mono<List<Step>> updateSteps(long recipeId, List<StepIn> stepsIn) {
|
||||
return repo.removeStepsFromRecipe(recipeId).then(addSteps(recipeId, stepsIn));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
public interface ToStepIn {
|
||||
StepIn toStepIn();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.core.step;
|
||||
|
||||
public interface ToStepOut {
|
||||
StepOut toStepOut();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package eu.bitfield.recipes.log;
|
||||
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.boolex.EvaluationException;
|
||||
import ch.qos.logback.core.boolex.EventEvaluatorBase;
|
||||
import ch.qos.logback.core.boolex.Matcher;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class EventMatcherEvaluator extends EventEvaluatorBase<ILoggingEvent> {
|
||||
@Getter @Setter String messageRegex = ".*";
|
||||
@Getter @Setter String loggerNameRegex = ".*";
|
||||
Matcher messageMatcher = new Matcher();
|
||||
Matcher loggerNameMatcher = new Matcher();
|
||||
|
||||
public boolean evaluate(ILoggingEvent event) throws EvaluationException {
|
||||
return messageMatcher.matches(event.getFormattedMessage()) && loggerNameMatcher.matches(event.getLoggerName());
|
||||
}
|
||||
|
||||
public void start() {
|
||||
messageMatcher.setName("messageMatcher");
|
||||
messageMatcher.setRegex(messageRegex);
|
||||
messageMatcher.start();
|
||||
|
||||
loggerNameMatcher.setName("messageMatcher");
|
||||
loggerNameMatcher.setRegex(loggerNameRegex);
|
||||
loggerNameMatcher.start();
|
||||
|
||||
super.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
super.stop();
|
||||
|
||||
loggerNameMatcher.stop();
|
||||
|
||||
messageMatcher.stop();
|
||||
}
|
||||
}
|
||||
4
src/main/java/eu/bitfield/recipes/package-info.java
Normal file
4
src/main/java/eu/bitfield/recipes/package-info.java
Normal file
@@ -0,0 +1,4 @@
|
||||
@NonNullApi
|
||||
package eu.bitfield.recipes;
|
||||
|
||||
import org.springframework.lang.NonNullApi;
|
||||
74
src/main/java/eu/bitfield/recipes/util/AsyncUtils.java
Normal file
74
src/main/java/eu/bitfield/recipes/util/AsyncUtils.java
Normal file
@@ -0,0 +1,74 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.function.UnaryOperator;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.util.function.Function.*;
|
||||
|
||||
|
||||
public class AsyncUtils {
|
||||
public static <T, R> Function<T, R> supplyFn(Supplier<R> supplier) {
|
||||
return (T t) -> supplier.get();
|
||||
}
|
||||
|
||||
public static <T> UnaryOperator<Mono<T>> errIfEmpty(Supplier<? extends Throwable> err) {
|
||||
return (Mono<T> mono) -> mono.switchIfEmpty(err(err));
|
||||
}
|
||||
|
||||
public static <T> UnaryOperator<Mono<T>> errIf(Predicate<T> shouldErr, Function<T, ? extends Throwable> err) {
|
||||
return (Mono<T> mono) -> mono.flatMap(item -> shouldErr.test(item) ? err(err.apply(item)) : some(item));
|
||||
}
|
||||
|
||||
public static <T> UnaryOperator<Mono<T>> errIfNot(Predicate<T> isOk, Function<T, ? extends Throwable> err) {
|
||||
return (Mono<T> mono) -> mono.flatMap(item -> isOk.test(item) ? some(item) : err(err.apply(item)));
|
||||
}
|
||||
|
||||
public static <T> Mono<T> some(T some) {return Mono.just(some);}
|
||||
|
||||
@SafeVarargs
|
||||
public static <T> Flux<T> many(T... many) {return Flux.just(many);}
|
||||
|
||||
public static <T> Mono<T> none() {return Mono.empty();}
|
||||
|
||||
public static <T> Mono<T> err(Supplier<? extends Throwable> error) {return Mono.error(error);}
|
||||
|
||||
public static <T> Mono<T> err(Throwable error) {return Mono.error(error);}
|
||||
|
||||
public static Function<Mono<Boolean>, Mono<Boolean>> errIfTrue(Supplier<? extends Throwable> err) {
|
||||
return (Mono<Boolean> mono) -> mono.flatMap(ok -> ok ? err(err.get()) : some(false));
|
||||
}
|
||||
|
||||
public static Function<Mono<Boolean>, Mono<Boolean>> errIfFalse(Supplier<? extends Throwable> errorSupplier) {
|
||||
return (Mono<Boolean> mono) -> mono.flatMap(ok -> ok ? some(true) : err(errorSupplier.get()));
|
||||
}
|
||||
|
||||
public static <T> Mono<T> supply(Supplier<? extends T> supplier) {return Mono.fromSupplier(supplier);}
|
||||
|
||||
public static <T> Flux<T> flux(Iterable<? extends T> iterable) {return Flux.fromIterable(iterable);}
|
||||
|
||||
public static <T> Flux<T> flux(Stream<? extends T> stream) {return Flux.fromStream(stream);}
|
||||
|
||||
public static <T1, T2> UnaryOperator<Mono<T1>> chain(Function<T1, Mono<? extends T2>> visit) {
|
||||
return (Mono<T1> mono) -> mono.flatMap(t1 -> visit.apply(t1).thenReturn(t1));
|
||||
}
|
||||
|
||||
public static <T> Mono<T> defer(MonoSupplier<? extends T> supplier) {return Mono.defer(supplier);}
|
||||
|
||||
public static <T> Flux<T> defer(FluxSupplier<T> supplier) {return Flux.defer(supplier);}
|
||||
|
||||
public static <T, V> UnaryOperator<Flux<T>> takeUntilChanged(Function<? super T, ? super V> keySelector) {
|
||||
return (Flux<T> items) -> items.windowUntilChanged(keySelector).take(1).flatMap(identity());
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface MonoSupplier<T> extends Supplier<Mono<T>> {}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface FluxSupplier<T> extends Supplier<Flux<T>> {}
|
||||
}
|
||||
11
src/main/java/eu/bitfield/recipes/util/Chronology.java
Normal file
11
src/main/java/eu/bitfield/recipes/util/Chronology.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
@Component
|
||||
public class Chronology {
|
||||
public Instant now() {return Instant.now().truncatedTo(ChronoUnit.MICROS);}
|
||||
}
|
||||
33
src/main/java/eu/bitfield/recipes/util/CollectionUtils.java
Normal file
33
src/main/java/eu/bitfield/recipes/util/CollectionUtils.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collector;
|
||||
|
||||
import static java.util.stream.Collectors.*;
|
||||
|
||||
public class CollectionUtils {
|
||||
public static <K, V> MultiValueMap<K, V> multiValueMap() {
|
||||
return MultiValueMap.fromMultiValue(new HashMap<>());
|
||||
}
|
||||
|
||||
public static <K, V> MultiValueMap<K, V> multiValueMap(Map<K, List<V>> map) {
|
||||
return MultiValueMap.fromMultiValue(map);
|
||||
}
|
||||
|
||||
public static <T> Collector<T, ?, HashSet<T>> toHashSet() {
|
||||
return toCollection(HashSet::new);
|
||||
}
|
||||
|
||||
public static <T> Collector<T, ?, ArrayList<T>> toArrayList() {
|
||||
return toCollection(ArrayList::new);
|
||||
}
|
||||
|
||||
public static <K, V, R> Function<Map.Entry<K, V>, R> entryFn(BiFunction<K, V, R> fn) {
|
||||
return entry -> fn.apply(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
8
src/main/java/eu/bitfield/recipes/util/Entity.java
Normal file
8
src/main/java/eu/bitfield/recipes/util/Entity.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import org.springframework.data.annotation.Immutable;
|
||||
|
||||
@Immutable
|
||||
public interface Entity extends Id {
|
||||
long id();
|
||||
}
|
||||
25
src/main/java/eu/bitfield/recipes/util/ErrorUtils.java
Normal file
25
src/main/java/eu/bitfield/recipes/util/ErrorUtils.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.ErrorResponseException;
|
||||
|
||||
public class ErrorUtils {
|
||||
public static ErrorResponseException errorResponseException(Throwable ex, HttpStatusCode statusCode) {
|
||||
ErrorResponse response = errorResponse(ex, statusCode);
|
||||
return new ErrorResponseException(response.getStatusCode(), response.getBody(), ex);
|
||||
}
|
||||
|
||||
public static ErrorResponse errorResponse(Throwable ex, HttpStatusCode statusCode) {
|
||||
StringBuilder messageBuilder = new StringBuilder(ex.getMessage());
|
||||
Throwable cause = ex.getCause();
|
||||
while (cause != null) {
|
||||
messageBuilder.append(" > ").append(cause.getMessage());
|
||||
cause = cause.getCause();
|
||||
}
|
||||
String message = messageBuilder.toString();
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(statusCode, message);
|
||||
return ErrorResponse.builder(ex, problemDetail).build();
|
||||
}
|
||||
}
|
||||
6
src/main/java/eu/bitfield/recipes/util/Id.java
Normal file
6
src/main/java/eu/bitfield/recipes/util/Id.java
Normal file
@@ -0,0 +1,6 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
public interface Id {
|
||||
long id();
|
||||
}
|
||||
|
||||
3
src/main/java/eu/bitfield/recipes/util/Pagination.java
Normal file
3
src/main/java/eu/bitfield/recipes/util/Pagination.java
Normal file
@@ -0,0 +1,3 @@
|
||||
package eu.bitfield.recipes.util;
|
||||
|
||||
public record Pagination(long limit, long offset) {}
|
||||
@@ -0,0 +1,39 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.Category;
|
||||
import eu.bitfield.recipes.core.category.CategoryOut;
|
||||
import eu.bitfield.recipes.core.ingredient.Ingredient;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientOut;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCat;
|
||||
import eu.bitfield.recipes.core.recipe.Recipe;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeOut;
|
||||
import eu.bitfield.recipes.core.step.Step;
|
||||
import eu.bitfield.recipes.core.step.StepOut;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record RecipeView(
|
||||
Recipe recipe,
|
||||
List<Category> categories,
|
||||
List<LinkRecCat> links,
|
||||
List<Ingredient> ingredients,
|
||||
List<Step> steps
|
||||
) implements ToRecipeViewOut {
|
||||
|
||||
public static RecipeViewOut createRecipeViewOut(
|
||||
Recipe recipe,
|
||||
List<Category> categories,
|
||||
List<Ingredient> ingredients,
|
||||
List<Step> steps)
|
||||
{
|
||||
RecipeOut recipeOut = recipe.toRecipeOut();
|
||||
List<CategoryOut> categoriesOut = categories.stream().map(Category::toCategoryOut).toList();
|
||||
List<IngredientOut> ingredientsOut = ingredients.stream().map(Ingredient::toIngredientOut).toList();
|
||||
List<StepOut> stepsOut = steps.stream().map(Step::toStepOut).toList();
|
||||
return new RecipeViewOut(recipeOut, categoriesOut, ingredientsOut, stepsOut);
|
||||
}
|
||||
|
||||
public RecipeViewOut toRecipeViewOut() {
|
||||
return createRecipeViewOut(recipe, categories, ingredients, steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||
import eu.bitfield.recipes.core.category.CategoryIn;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientIn;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeIn;
|
||||
import eu.bitfield.recipes.core.step.StepIn;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.With;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@With
|
||||
public record RecipeViewIn(
|
||||
@JsonUnwrapped @NotNull @Valid RecipeIn recipe,
|
||||
@NotNull List<@Valid @NotNull CategoryIn> categories,
|
||||
@NotEmpty List<@Valid @NotNull IngredientIn> ingredients,
|
||||
@NotEmpty List<@Valid @NotNull StepIn> steps
|
||||
) implements ToRecipeViewIn {
|
||||
public RecipeViewIn toRecipeViewIn() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||
import eu.bitfield.recipes.core.category.CategoryOut;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientOut;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeOut;
|
||||
import eu.bitfield.recipes.core.recipe.ToRecipeOut;
|
||||
import eu.bitfield.recipes.core.step.StepOut;
|
||||
import lombok.With;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@With
|
||||
public record RecipeViewOut(
|
||||
@JsonUnwrapped RecipeOut recipe,
|
||||
List<CategoryOut> categories,
|
||||
List<IngredientOut> ingredients,
|
||||
List<StepOut> steps
|
||||
) implements ToRecipeOut {
|
||||
public RecipeOut toRecipeOut() {
|
||||
return recipe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
public interface ToRecipeViewIn {
|
||||
RecipeViewIn toRecipeViewIn();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package eu.bitfield.recipes.view.recipe;
|
||||
|
||||
public interface ToRecipeViewOut {
|
||||
RecipeViewOut toRecipeViewOut();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package eu.bitfield.recipes.view.registration;
|
||||
|
||||
import eu.bitfield.recipes.core.account.Account;
|
||||
import eu.bitfield.recipes.core.profile.Profile;
|
||||
|
||||
public record RegistrationView(
|
||||
Profile profile,
|
||||
Account account
|
||||
) {}
|
||||
31
src/main/resources/application.yaml
Normal file
31
src/main/resources/application.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
spring:
|
||||
application:
|
||||
name: recipes
|
||||
r2dbc:
|
||||
url: "r2dbc:postgresql://localhost:5432/recipes"
|
||||
username: "recipes"
|
||||
password: "dev_pw"
|
||||
main:
|
||||
banner-mode: "off"
|
||||
web-application-type: reactive
|
||||
webflux:
|
||||
problemdetails:
|
||||
enabled: true
|
||||
|
||||
# jackson:
|
||||
# serialization:
|
||||
# INDENT_OUTPUT: true
|
||||
#logging:
|
||||
# level:
|
||||
# io.r2dbc.pool: debug
|
||||
# io.r2dbc.postgresql.QUERY: debug
|
||||
# io.r2dbc.postgresql.PARAM: debug
|
||||
# org.springframework.r2dbc: debug
|
||||
# org.springframework.transaction: trace
|
||||
# org.springframework.r2dbc.connection.init.ScriptUtils: info
|
||||
# eu.bitfield.recipes.test.api.APICall: debug
|
||||
# eu.bitfield.recipes.api.ErrorResponseHandling: info
|
||||
# org.springframework.security: debug
|
||||
# reactor.netty.http.client: debug
|
||||
# reactor.netty.http.server: debug
|
||||
# org.springframework: debug
|
||||
26
src/main/resources/logback.xml
Normal file
26
src/main/resources/logback.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_LOG_THRESHOLD}</level>
|
||||
</filter>
|
||||
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
|
||||
<evaluator class="eu.bitfield.recipes.log.EventMatcherEvaluator">
|
||||
<loggerNameRegex>^io\.r2dbc\.postgresql\.QUERY$</loggerNameRegex>
|
||||
<messageRegex>Executing query: SHOW TRANSACTION ISOLATION LEVEL</messageRegex>
|
||||
</evaluator>
|
||||
<OnMatch>DENY</OnMatch>
|
||||
<OnMismatch>NEUTRAL</OnMismatch>
|
||||
</filter>
|
||||
<encoder>
|
||||
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
|
||||
<charset>${CONSOLE_LOG_CHARSET}</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
<logger name="org.springframework" level="WARN"/>
|
||||
</configuration>
|
||||
49
src/main/resources/schema.sql
Normal file
49
src/main/resources/schema.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
drop table if exists profile cascade;
|
||||
create table profile (
|
||||
id bigint primary key generated always as identity
|
||||
);
|
||||
|
||||
drop table if exists account cascade;
|
||||
create table account (
|
||||
id bigint primary key generated always as identity,
|
||||
profile_id bigint references profile on delete cascade,
|
||||
email text not null unique,
|
||||
password text not null
|
||||
);
|
||||
|
||||
drop table if exists recipe cascade;
|
||||
create table recipe (
|
||||
id bigint primary key generated always as identity,
|
||||
author_profile_id bigint not null references profile on delete cascade,
|
||||
name text not null,
|
||||
description text not null,
|
||||
changed_at timestamp not null
|
||||
);
|
||||
|
||||
drop table if exists category cascade;
|
||||
create table category (
|
||||
id bigint primary key generated always as identity,
|
||||
name text not null unique
|
||||
);
|
||||
|
||||
drop table if exists link_rec_cat cascade;
|
||||
create table link_rec_cat (
|
||||
id bigint primary key generated always as identity,
|
||||
recipe_id bigint not null references recipe on delete cascade,
|
||||
category_id bigint not null references category on delete cascade,
|
||||
unique (recipe_id, category_id)
|
||||
);
|
||||
|
||||
drop table if exists ingredient cascade;
|
||||
create table ingredient (
|
||||
id bigint primary key generated always as identity,
|
||||
recipe_id bigint references recipe on delete cascade,
|
||||
name text not null
|
||||
);
|
||||
|
||||
drop table if exists step cascade;
|
||||
create table step (
|
||||
id bigint primary key generated always as identity,
|
||||
recipe_id bigint references recipe on delete cascade,
|
||||
name text not null
|
||||
);
|
||||
178
src/test/java/eu/bitfield/recipes/api/APITest.java
Normal file
178
src/test/java/eu/bitfield/recipes/api/APITest.java
Normal file
@@ -0,0 +1,178 @@
|
||||
package eu.bitfield.recipes.api;
|
||||
|
||||
import eu.bitfield.recipes.api.recipe.RecipeEndpoint;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeOut;
|
||||
import eu.bitfield.recipes.test.api.APICalls;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeTemplate;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import io.r2dbc.spi.ConnectionFactory;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.InvalidEntity.*;
|
||||
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
@Getter @Accessors(fluent = true)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
public class APITest implements AccountQueries, RecipeViewQueries, APICalls {
|
||||
static final Pagination pagination = new Pagination(RecipeEndpoint.MAX_LIMIT, 0L);
|
||||
|
||||
@Autowired WebTestClient client;
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers().profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers().accountLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers().recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers().categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkLayer = layers().linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers().ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers().stepLayer();
|
||||
|
||||
static void cleanDatabase(ConnectionFactory connectionFactory, Resource cleanSql) {
|
||||
var resourceDatabasePopulator = new ResourceDatabasePopulator(cleanSql);
|
||||
resourceDatabasePopulator.addScript(cleanSql);
|
||||
resourceDatabasePopulator.populate(connectionFactory).block();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void afterAll(@Autowired ConnectionFactory connectionFactory,
|
||||
@Value("classpath:/clean.sql") Resource cleanSql)
|
||||
{
|
||||
cleanDatabase(connectionFactory, cleanSql);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void beforeEach(@Autowired ConnectionFactory connectionFactory,
|
||||
@Value("classpath:/clean.sql") Resource cleanSql)
|
||||
{
|
||||
cleanDatabase(connectionFactory, cleanSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authentificationRequired() {
|
||||
long recipeId = freeId;
|
||||
RecipeViewIn viewIn = recipeViewGroup().toRecipeViewIn();
|
||||
addRecipeCall(viewIn).anonymous().unauthorized();
|
||||
updateRecipeCall(recipeId, viewIn).anonymous().unauthorized();
|
||||
removeRecipeCall(recipeId).anonymous().unauthorized();
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountReregistration() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
|
||||
registerAccountCall(account).ok();
|
||||
registerAccountCall(account).badRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeCrudFlow() {
|
||||
AccountSlot author = accountSlot(profiles.ada).blank();
|
||||
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
|
||||
RecipeViewGroup group = recipeViewGroup(recipe);
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
|
||||
String updatedName = viewIn.recipe().name() + " v2";
|
||||
RecipeTemplate updatedRecipeTemplate = group.recipe().template().withName(updatedName);
|
||||
RecipeSlot updatedRecipe = recipeSlot().template(updatedRecipeTemplate).author(author.profile()).blank();
|
||||
RecipeViewGroup updatedGroup = recipeViewGroup(updatedRecipe);
|
||||
RecipeViewIn updatedViewIn = updatedGroup.toRecipeViewIn();
|
||||
|
||||
save(combinedSlots(group, updatedGroup));
|
||||
|
||||
registerAccountCall(author).ok();
|
||||
registerAccountCall(notAuthor).ok();
|
||||
|
||||
RecipeViewOut addedViewOut = addRecipeCall(viewIn).as(author).ok();
|
||||
assertThat(addedViewOut).isEqualTo(expectedViewOut(addedViewOut, group));
|
||||
|
||||
long recipeId = addedViewOut.recipe().id();
|
||||
|
||||
RecipeViewOut gotViewOut = getRecipeCall(recipeId).ok();
|
||||
assertThat(addedViewOut).isEqualTo(gotViewOut);
|
||||
|
||||
removeRecipeCall(recipeId).as(notAuthor).forbidden();
|
||||
getRecipeCall(recipeId).ok();
|
||||
|
||||
updateRecipeCall(recipeId, updatedViewIn).as(notAuthor).forbidden();
|
||||
gotViewOut = getRecipeCall(recipeId).ok();
|
||||
assertThat(addedViewOut).isEqualTo(gotViewOut);
|
||||
|
||||
RecipeViewOut updatedViewOut = updateRecipeCall(recipeId, updatedViewIn).as(author).ok();
|
||||
assertThat(updatedViewOut).isEqualTo(expectedViewOut(updatedViewOut, updatedGroup));
|
||||
|
||||
gotViewOut = getRecipeCall(recipeId).ok();
|
||||
assertThat(gotViewOut).isEqualTo(updatedViewOut);
|
||||
|
||||
removeRecipeCall(recipeId).as(author).ok();
|
||||
getRecipeCall(recipeId).notFound();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void recipeSearch() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
registerAccountCall(account).ok();
|
||||
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewOut> allViewsOut = allGroups.stream()
|
||||
.filter(group -> !group.ingredients().isEmpty() && !group.steps().isEmpty())
|
||||
.map(group -> addRecipeCall(group.toRecipeViewIn()).as(account).ok())
|
||||
.toList();
|
||||
allViewsOut = allViewsOut.reversed(); // newest recipe first
|
||||
|
||||
String categoryName = categorySlotWithLinkCount(2).blank().name();
|
||||
List<RecipeViewOut> viewsOut = allViewsOut.stream()
|
||||
.filter((RecipeViewOut viewOut) -> viewOut.categories().stream().anyMatch(hasCategoryName(categoryName)))
|
||||
.toList();
|
||||
List<RecipeViewOut> actualViewsOut = findRecipesCall().category(categoryName).pagination(pagination).ok();
|
||||
assertThat(actualViewsOut).isEqualTo(viewsOut);
|
||||
|
||||
String recipeName = "ma";
|
||||
viewsOut = allViewsOut.stream().filter(containsRecipeName(recipeName)).toList();
|
||||
actualViewsOut = findRecipesCall().recipe(recipeName).pagination(pagination).ok();
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
|
||||
}
|
||||
|
||||
|
||||
private RecipeViewOut expectedViewOut(RecipeViewOut actual, RecipeViewGroup group) {
|
||||
RecipeViewOut assumed = group.toRecipeViewOut();
|
||||
long actualRecipeId = actual.recipe().id();
|
||||
Instant actualChangedAt = actual.recipe().changedAt();
|
||||
RecipeOut expectedRecipe = assumed.recipe().withId(actualRecipeId).withChangedAt(actualChangedAt);
|
||||
return assumed.withRecipe(expectedRecipe);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package eu.bitfield.recipes.api.account;
|
||||
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddressIn;
|
||||
import eu.bitfield.recipes.auth.password.PasswordIn;
|
||||
import eu.bitfield.recipes.core.account.AccountIn;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import eu.bitfield.recipes.test.api.APICalls;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.view.registration.RegistrationView;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.TestUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Getter @Accessors(fluent = true)
|
||||
@WebFluxTest(value = AccountEndpoint.class, excludeAutoConfiguration = {
|
||||
ReactiveUserDetailsServiceAutoConfiguration.class,
|
||||
ReactiveSecurityAutoConfiguration.class
|
||||
})
|
||||
public class AccountEndpointTest implements AccountQueries, APICalls {
|
||||
@MockitoBean RegisterAccount registerAccount;
|
||||
@Autowired WebTestClient client;
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
|
||||
@Test
|
||||
void registerAccount_accountInEmailOk_ok() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
var registrationView = new RegistrationView(account.profile().saved(), account.saved());
|
||||
|
||||
when(registerAccount.registerAccount(accountIn)).thenReturn(some(registrationView));
|
||||
|
||||
registerAccountCall(accountIn).ok();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerAccount_accountInEmailInUse_badRequest() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
|
||||
var err = new RegisterAccount.EmailAlreadyInUse(new AccountService.EmailAlreadyInUse(account.email()));
|
||||
|
||||
when(registerAccount.registerAccount(accountIn)).thenReturn(err(err));
|
||||
|
||||
registerAccountCall(accountIn).badRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerAccount_invalidAccountIn_badRequest() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
EmailAddressIn invalidEmail = EmailAddressIn.ofUnchecked(accountIn.email().address() + "@@");
|
||||
PasswordIn invalidPassword = PasswordIn.ofUnchecked("shortpw");
|
||||
accountIn = accountIn.withEmail(invalidEmail).withPassword(invalidPassword);
|
||||
|
||||
when(registerAccount.registerAccount(any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
registerAccountCall(accountIn).badRequest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.bitfield.recipes.api.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.auth.password.Password;
|
||||
import eu.bitfield.recipes.core.account.AccountIn;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import eu.bitfield.recipes.core.profile.ProfileService;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.view.registration.RegistrationView;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.TestUtils.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class RegisterAccountTest implements AccountQueries {
|
||||
ProfileService profileServ = mock(ProfileService.class);
|
||||
AccountService accountServ = mock(AccountService.class);
|
||||
RegisterAccount registerAccount = new RegisterAccount(profileServ, accountServ);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
|
||||
@Test
|
||||
void register_accountInWithUnusedEmail_accountRegistrationRegisterAccount() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
EmailAddress email = account.email();
|
||||
Password password = account.password();
|
||||
var registrationView = new RegistrationView(account.profile().saved(), account.saved());
|
||||
|
||||
when(accountServ.checkEmail(email)).thenReturn(some(email));
|
||||
when(profileServ.addProfile()).thenReturn(some(account.profile().saved()));
|
||||
when(accountServ.encode(account.passwordIn())).thenReturn(password);
|
||||
when(accountServ.addAccount(account.profile().id(), email, password)).thenReturn(some(account.saved()));
|
||||
|
||||
registerAccount.registerAccount(accountIn)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(registrationView)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerAccount_accountInWithUsedEmail_errorEmailAlreadyInUse() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
AccountIn accountIn = account.toAccountIn();
|
||||
EmailAddress email = account.email();
|
||||
|
||||
when(accountServ.checkEmail(email)).thenReturn(err(new AccountService.EmailAlreadyInUse(email)));
|
||||
when(profileServ.addProfile()).thenReturn(noSubscriptionMono());
|
||||
when(accountServ.addAccount(anyLong(), any(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
registerAccount.registerAccount(accountIn)
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(RegisterAccount.EmailAlreadyInUse.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Chronology;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
|
||||
public class AddRecipeTest implements RecipeViewQueries {
|
||||
RecipeService recipeServ = mock(RecipeService.class);
|
||||
CategoryService categoryServ = mock(CategoryService.class);
|
||||
LinkRecCatService linkServ = mock(LinkRecCatService.class);
|
||||
IngredientService ingredientServ = mock(IngredientService.class);
|
||||
StepService stepServ = mock(StepService.class);
|
||||
Chronology time = mock(Chronology.class);
|
||||
AddRecipe addRecipe = new AddRecipe(recipeServ, categoryServ, linkServ, ingredientServ, stepServ, time);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void addRecipe_anyRecipeViewIn_recipeViewAddRecipe() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeSlot recipe = group.recipe();
|
||||
Instant createdAt = recipe.saved().changedAt();
|
||||
|
||||
when(time.now()).thenReturn(createdAt);
|
||||
when(recipeServ.addRecipe(recipe.author().id(), viewIn.recipe(), createdAt)).thenReturn(some(recipe.saved()));
|
||||
when(categoryServ.addCategories(viewIn.categories())).thenReturn(some(view.categories()));
|
||||
when(linkServ.addLinks(recipe.id(), view.categories())).thenReturn(some(view.links()));
|
||||
when(ingredientServ.addIngredients(recipe.id(), viewIn.ingredients())).thenReturn(some(view.ingredients()));
|
||||
when(stepServ.addSteps(recipe.id(), viewIn.steps())).thenReturn(some(view.steps()));
|
||||
|
||||
addRecipe.addRecipe(viewIn, recipe.author().id())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(view)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.To.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class FindRecipesTest implements RecipeViewQueries {
|
||||
RecipeService recipeServ = mock(RecipeService.class);
|
||||
GetRecipe getRecipe = mock(GetRecipe.class);
|
||||
FindRecipes findRecipes = new FindRecipes(recipeServ, getRecipe);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void findRecipes_categoryName_recipeViewsOut() {
|
||||
String categoryName = categorySlotWithLinkCount(2).blank().name();
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewGroup> groups = allGroups.stream()
|
||||
.filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName)))
|
||||
.sorted(orderByRecipeChangedAtDesc())
|
||||
.toList();
|
||||
List<RecipeViewOut> savedViewsOut = groups.stream().map(toRecipeViewOut).toList();
|
||||
List<Long> recipeIds = groups.stream().map(toRecipe).map(toId).toList();
|
||||
long recipeCount = recipeIds.size();
|
||||
var pagination = new Pagination(recipeCount, 0);
|
||||
String recipeName = null;
|
||||
|
||||
for (var group : allGroups) {
|
||||
when(getRecipe.getRecipe(group.recipe().id())).thenReturn(some(group.toRecipeViewOut()));
|
||||
}
|
||||
when(recipeServ.findRecipeIds(categoryName, recipeName, pagination)).thenReturn(flux(recipeIds));
|
||||
|
||||
findRecipes.findRecipes(categoryName, recipeName, pagination)
|
||||
.as(StepVerifier::create)
|
||||
.assertNext((List<RecipeViewOut> actualViewsOut) -> {
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_recipeNamePart_recipeViewsOut() {
|
||||
String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewGroup> groups = allGroups.stream()
|
||||
.filter(containsRecipeName(recipeName))
|
||||
.sorted(orderByRecipeChangedAtDesc())
|
||||
.toList();
|
||||
List<RecipeViewOut> savedViewsOut = groups.stream().map(toRecipeViewOut).toList();
|
||||
List<Long> recipeIds = groups.stream().map(toRecipe).map(toId).toList();
|
||||
long recipeCount = recipeIds.size();
|
||||
var pagination = new Pagination(recipeCount, 0);
|
||||
String categoryName = null;
|
||||
|
||||
for (var group : allGroups) {
|
||||
when(getRecipe.getRecipe(group.recipe().id())).thenReturn(some(group.toRecipeViewOut()));
|
||||
}
|
||||
when(recipeServ.findRecipeIds(categoryName, recipeName, pagination)).thenReturn(flux(recipeIds));
|
||||
|
||||
findRecipes.findRecipes(categoryName, recipeName, pagination)
|
||||
.as(StepVerifier::create)
|
||||
.assertNext((List<RecipeViewOut> actualViewsOut) -> {
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.InvalidEntity.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class GetRecipeTest implements RecipeViewQueries {
|
||||
RecipeService recipeServ = mock(RecipeService.class);
|
||||
CategoryService categoryServ = mock(CategoryService.class);
|
||||
IngredientService ingredientServ = mock(IngredientService.class);
|
||||
StepService stepServ = mock(StepService.class);
|
||||
GetRecipe getRecipe = new GetRecipe(recipeServ, categoryServ, ingredientServ, stepServ);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void getRecipe_nonExistentRecipeId_errorNotFound() {
|
||||
long recipeId = freeId;
|
||||
|
||||
when(recipeServ.getRecipe(recipeId)).thenReturn(err(new RecipeService.RecipeNotFound(freeId)));
|
||||
|
||||
getRecipe.getRecipe(recipeId)
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(GetRecipe.NotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecipe_existentRecipeId_recipeViewOutGetRecipe() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
RecipeSlot recipe = group.recipe();
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeViewOut viewOut = group.toRecipeViewOut();
|
||||
|
||||
when(recipeServ.getRecipe(recipe.id())).thenReturn(some(view.recipe()));
|
||||
when(categoryServ.getCategories(recipe.id())).thenReturn(some(view.categories()));
|
||||
when(ingredientServ.getIngredients(recipe.id())).thenReturn(some(view.ingredients()));
|
||||
when(stepServ.getSteps(recipe.id())).thenReturn(some(view.steps()));
|
||||
|
||||
getRecipe.getRecipe(recipe.id())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(viewOut)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.auth.ProfileIdentityAccess;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.test.InvalidEntity;
|
||||
import eu.bitfield.recipes.test.api.APICalls;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Pagination;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.api.recipe.RecipeEndpoint.*;
|
||||
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.TestUtils.*;
|
||||
import static eu.bitfield.recipes.util.To.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@WebFluxTest(value = RecipeEndpoint.class, excludeAutoConfiguration = {
|
||||
ReactiveUserDetailsServiceAutoConfiguration.class,
|
||||
ReactiveSecurityAutoConfiguration.class
|
||||
})
|
||||
@Getter @Accessors(fluent = true)
|
||||
public class RecipeEndpointTest implements RecipeViewQueries, AccountQueries, APICalls {
|
||||
static final Pagination pagination = new Pagination(RecipeEndpoint.MAX_LIMIT, 0L);
|
||||
static final long freeRecipeId = InvalidEntity.freeId;
|
||||
|
||||
@MockitoBean AddRecipe addRecipe;
|
||||
@MockitoBean GetRecipe getRecipe;
|
||||
@MockitoBean UpdateRecipe updateRecipe;
|
||||
@MockitoBean RemoveRecipe removeRecipe;
|
||||
@MockitoBean FindRecipes findRecipes;
|
||||
@MockitoBean ProfileIdentityAccess profileIdentity;
|
||||
|
||||
@Autowired WebTestClient client;
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void addRecipe_validRecipeIn_okRecipeId() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
|
||||
save(slots(group).add(author));
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeViewOut viewOut = group.toRecipeViewOut();
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(author.profile().id()));
|
||||
when(addRecipe.addRecipe(viewIn, author.profile().id())).thenReturn(some(view));
|
||||
|
||||
RecipeViewOut actualViewOut = addRecipeCall(viewIn).as(author).ok();
|
||||
assertThat(actualViewOut).isEqualTo(viewOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRecipe_invalidRecipeIn_badRequest() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
|
||||
save(slots(group).add(author));
|
||||
RecipeViewIn viewIn = invalid(group.toRecipeViewIn());
|
||||
|
||||
when(profileIdentity.id()).thenReturn(noSubscriptionMono());
|
||||
when(addRecipe.addRecipe(any(), anyLong())).thenReturn(noSubscriptionMono());
|
||||
|
||||
addRecipeCall(viewIn).as(author).badRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecipe_existentRecipeId_okRecipeViewOut() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
RecipeViewOut viewOut = group.toRecipeViewOut();
|
||||
long recipeId = group.recipe().id();
|
||||
|
||||
when(getRecipe.getRecipe(recipeId)).thenReturn(some(viewOut));
|
||||
|
||||
RecipeViewOut actualViewOut = getRecipeCall(recipeId).ok();
|
||||
assertThat(actualViewOut).isEqualTo(viewOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecipe_nonExistentRecipeId_notFound() {
|
||||
var err = new GetRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
|
||||
|
||||
when(getRecipe.getRecipe(freeRecipeId)).thenReturn(err(err));
|
||||
|
||||
getRecipeCall(freeRecipeId).notFound();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_existentRecipeId$validRecipeViewIn$author_ok() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
|
||||
save(slots(group).add(author));
|
||||
RecipeSlot recipe = group.recipe();
|
||||
long profileId = recipe.author().id();
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeViewOut viewOut = group.toRecipeViewOut();
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(profileId));
|
||||
when(updateRecipe.updateRecipe(recipe.id(), viewIn, profileId)).thenReturn(some(view));
|
||||
|
||||
RecipeViewOut actualViewOut = updateRecipeCall(recipe.id(), viewIn).as(author).ok();
|
||||
assertThat(actualViewOut).isEqualTo(viewOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_nonExistentRecipeId$validRecipeViewIn_notFound() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot account = accountSlot().profile(group.recipe().author()).blank();
|
||||
save(slots(group).add(account));
|
||||
long profileId = group.recipe().author().id();
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
var err = new UpdateRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(profileId));
|
||||
when(updateRecipe.updateRecipe(freeRecipeId, viewIn, profileId)).thenReturn(err(err));
|
||||
|
||||
updateRecipeCall(freeRecipeId, viewIn).as(account).notFound();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_existentRecipeId$ValidRecipeViewIn$notAuthor_forbidden() {
|
||||
AccountSlot author = accountSlot(profiles.ada).blank();
|
||||
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
|
||||
RecipeViewGroup group = recipeViewGroup(recipe);
|
||||
save(slots(group).add(author, notAuthor));
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
long recipeId = group.recipe().id();
|
||||
var err = new UpdateRecipe.Forbidden(new RecipeService.UpdateRecipeForbidden(recipeId));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(notAuthor.profile().id()));
|
||||
when(updateRecipe.updateRecipe(recipeId, viewIn, notAuthor.profile().id())).thenReturn(err(err));
|
||||
|
||||
updateRecipeCall(recipeId, viewIn).as(notAuthor).forbidden();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_invalidRecipeViewIn_badRequest() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
AccountSlot account = accountSlot().profile(group.recipe().author()).blank();
|
||||
RecipeViewIn viewIn = invalid(group.toRecipeViewIn());
|
||||
|
||||
when(profileIdentity.id()).thenReturn(noSubscriptionMono());
|
||||
when(updateRecipe.updateRecipe(anyLong(), any(), anyLong())).thenReturn(noSubscriptionMono());
|
||||
|
||||
updateRecipeCall(freeRecipeId, viewIn).as(account).badRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_existentRecipeId$author_ok() {
|
||||
AccountSlot author = accountSlot().blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
|
||||
save(slot(author).add(recipe));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(author.profile().id()));
|
||||
when(removeRecipe.removeRecipe(recipe.id(), author.profile().id())).thenReturn(some(true));
|
||||
|
||||
removeRecipeCall(recipe.id()).as(author).ok();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_nonExistentRecipeId_notFound() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
var err = new RemoveRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(account.profile().id()));
|
||||
when(removeRecipe.removeRecipe(freeRecipeId, account.profile().id())).thenReturn(err(err));
|
||||
|
||||
removeRecipeCall(freeRecipeId).as(account).notFound();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_existentRecipeId$notAuthor_forbidden() {
|
||||
AccountSlot author = accountSlot(profiles.ada).blank();
|
||||
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
|
||||
save(slot(recipe).add(author, notAuthor));
|
||||
var err = new RemoveRecipe.Forbidden(new RecipeService.RemoveRecipeForbidden(recipe.id()));
|
||||
|
||||
when(profileIdentity.id()).thenReturn(some(notAuthor.profile().id()));
|
||||
when(removeRecipe.removeRecipe(recipe.id(), notAuthor.profile().id())).thenReturn(err(err));
|
||||
|
||||
removeRecipeCall(recipe.id()).as(notAuthor).forbidden();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_someCategoryName$noRecipeName_okAllRecipeViewOut() {
|
||||
String categoryName = categorySlotWithLinkCount(2).blank().name();
|
||||
String recipeName = null;
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewGroup> groups = allGroups.stream()
|
||||
.filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName)))
|
||||
.sorted(orderByRecipeChangedAtDesc())
|
||||
.toList();
|
||||
List<RecipeViewOut> viewsOut = groups.stream().map(toRecipeViewOut).toList();
|
||||
|
||||
when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut));
|
||||
|
||||
List<RecipeViewOut> actualViewsOut = findRecipesCall().category(categoryName).pagination(pagination).ok();
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_noCategoryName$someRecipeName_okAllRecipeViewOut() {
|
||||
String categoryName = null;
|
||||
String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra
|
||||
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
|
||||
save(combinedSlots(allGroups));
|
||||
List<RecipeViewGroup> groups = allGroups.stream()
|
||||
.filter(containsRecipeName(recipeName))
|
||||
.sorted(orderByRecipeChangedAtDesc())
|
||||
.toList();
|
||||
List<RecipeViewOut> viewsOut = groups.stream().map(RecipeViewGroup::toRecipeViewOut).toList();
|
||||
|
||||
when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut));
|
||||
|
||||
List<RecipeViewOut> actualViewsOut = findRecipesCall().recipe(recipeName).pagination(pagination).ok();
|
||||
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_invalidLimit_badRequest() {
|
||||
when(findRecipes.findRecipes(any(), any(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
for (long limit : List.of(MIN_LIMIT - 1, MAX_LIMIT + 1)) {
|
||||
findRecipesCall().recipe("any").limit(limit).offset(0L).badRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecipes_invalidOffset_badRequestProblemDetail() {
|
||||
when(findRecipes.findRecipes(any(), any(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
findRecipesCall().recipe("any").limit(MAX_LIMIT).offset(-1L).badRequest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileSlot;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeQueries;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.InvalidEntity.*;
|
||||
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class RemoveRecipeTest implements RecipeQueries {
|
||||
RecipeService serv = mock(RecipeService.class);
|
||||
RemoveRecipe removeRecipe = new RemoveRecipe(serv);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
|
||||
@Test
|
||||
void removeRecipe_nonExistentRecipeId_errorNotFound() {
|
||||
long recipeId = freeId;
|
||||
ProfileSlot profile = profileSlot().blank();
|
||||
save(slot(profile));
|
||||
|
||||
when(serv.removeRecipe(recipeId, profile.id())).thenReturn(err(new RecipeService.RecipeNotFound(recipeId)));
|
||||
|
||||
removeRecipe.removeRecipe(recipeId, profile.id())
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(RemoveRecipe.NotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_notAuthorProfileId_errorForbidden() {
|
||||
ProfileSlot author = profileSlot(profiles.ada).blank();
|
||||
ProfileSlot notAuthor = profileSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author).blank();
|
||||
save(slots(author, notAuthor).add(recipe));
|
||||
|
||||
when(serv.removeRecipe(recipe.id(), notAuthor.id())).thenReturn(err(new RemoveRecipeForbidden(recipe.id())));
|
||||
|
||||
removeRecipe.removeRecipe(recipe.id(), notAuthor.id())
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(RemoveRecipe.Forbidden.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeRecipe_anyRecipeIn_removedRecipe() {
|
||||
RecipeSlot recipe = recipeSlot().blank();
|
||||
save(slot(recipe));
|
||||
|
||||
when(serv.removeRecipe(recipe.id(), recipe.author().id())).thenReturn(some(true));
|
||||
|
||||
removeRecipe.removeRecipe(recipe.id(), recipe.author().id())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package eu.bitfield.recipes.api.recipe;
|
||||
|
||||
import eu.bitfield.recipes.core.category.CategoryService;
|
||||
import eu.bitfield.recipes.core.ingredient.Ingredient;
|
||||
import eu.bitfield.recipes.core.ingredient.IngredientService;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatService;
|
||||
import eu.bitfield.recipes.core.recipe.Recipe;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeIn;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden;
|
||||
import eu.bitfield.recipes.core.step.StepService;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileSlot;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.core.step.StepLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
|
||||
import eu.bitfield.recipes.util.Chronology;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeView;
|
||||
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.InvalidEntity.*;
|
||||
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.TestUtils.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class UpdateRecipeTest implements RecipeViewQueries {
|
||||
RecipeService recipeServ = mock(RecipeService.class);
|
||||
CategoryService categoryServ = mock(CategoryService.class);
|
||||
LinkRecCatService linkServ = mock(LinkRecCatService.class);
|
||||
StepService stepServ = mock(StepService.class);
|
||||
IngredientService ingredientServ = mock(IngredientService.class);
|
||||
Chronology time = mock(Chronology.class);
|
||||
UpdateRecipe updateRecipe = new UpdateRecipe(recipeServ, categoryServ, linkServ, stepServ, ingredientServ, time);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
|
||||
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
|
||||
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
|
||||
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
|
||||
@Delegate StepLayer stepLayer = layers.stepLayer();
|
||||
|
||||
@Test
|
||||
void updateRecipe_nonExistentRecipeId_errorNotFound() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
long recipeId = freeId;
|
||||
long profileId = group.recipe().author().id();
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
Instant changedAt = realTime().now();
|
||||
var err = new RecipeNotFound(recipeId);
|
||||
|
||||
when(time.now()).thenReturn(changedAt);
|
||||
when(recipeServ.updateRecipe(recipeId, profileId, viewIn.recipe(), changedAt)).thenReturn(err(err));
|
||||
when(categoryServ.addCategories(any())).thenReturn(noSubscriptionMono());
|
||||
when(linkServ.updateLinks(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
when(stepServ.updateSteps(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
when(ingredientServ.updateIngredients(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
updateRecipe.updateRecipe(recipeId, viewIn, profileId)
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(UpdateRecipe.NotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_notAuthorProfileId_errorForbidden() {
|
||||
ProfileSlot author = profileSlot(profiles.ada).blank();
|
||||
ProfileSlot notAuthor = profileSlot(profiles.bea).blank();
|
||||
RecipeSlot recipe = recipeSlot().author(author).blank();
|
||||
RecipeViewGroup group = recipeViewGroup(recipe);
|
||||
save(slots(group).add(author, notAuthor));
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
RecipeIn recipeIn = viewIn.recipe();
|
||||
Instant changedAt = realTime().now();
|
||||
var err = new UpdateRecipeForbidden(recipe.id());
|
||||
|
||||
when(time.now()).thenReturn(changedAt);
|
||||
when(recipeServ.updateRecipe(recipe.id(), notAuthor.id(), recipeIn, changedAt)).thenReturn(err(err));
|
||||
when(categoryServ.addCategories(any())).thenReturn(noSubscriptionMono());
|
||||
when(linkServ.updateLinks(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
when(stepServ.updateSteps(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
when(ingredientServ.updateIngredients(anyLong(), any())).thenReturn(noSubscriptionMono());
|
||||
|
||||
updateRecipe.updateRecipe(recipe.id(), viewIn, notAuthor.id())
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(UpdateRecipe.Forbidden.class);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRecipe_anyRecipeIn_recipeView() {
|
||||
RecipeViewGroup group = recipeViewGroup();
|
||||
save(slots(group));
|
||||
RecipeView view = group.toRecipeView();
|
||||
RecipeViewIn viewIn = group.toRecipeViewIn();
|
||||
Recipe recipe = view.recipe();
|
||||
List<Ingredient> ingredients = view.ingredients();
|
||||
long recipeId = recipe.id();
|
||||
long profileId = recipe.authorProfileId();
|
||||
Instant changedAt = recipe.changedAt();
|
||||
|
||||
when(time.now()).thenReturn(changedAt);
|
||||
when(recipeServ.updateRecipe(recipeId, profileId, viewIn.recipe(), changedAt)).thenReturn(some(recipe));
|
||||
when(categoryServ.addCategories(viewIn.categories())).thenReturn(some(view.categories()));
|
||||
when(linkServ.updateLinks(recipeId, view.categories())).thenReturn(some(view.links()));
|
||||
when(stepServ.updateSteps(recipeId, viewIn.steps())).thenReturn(some(view.steps()));
|
||||
when(ingredientServ.updateIngredients(recipeId, viewIn.ingredients())).thenReturn(some(ingredients));
|
||||
|
||||
updateRecipe.updateRecipe(recipeId, viewIn, profileId)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(view)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package eu.bitfield.recipes.auth;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.core.account.AccountService;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class AccountPrincipalServiceTest implements AccountQueries {
|
||||
AccountService accountServ = mock(AccountService.class);
|
||||
AccountPrincipalService serv = new AccountPrincipalService(accountServ);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
|
||||
@Test
|
||||
void findByUserName_existentAccountEmail_accountPrincipal() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
String address = account.emailIn().address().toUpperCase();
|
||||
var principal = new AccountPrincipal(account.saved());
|
||||
|
||||
when(accountServ.getAccount(account.email())).thenReturn(some(account.saved()));
|
||||
|
||||
serv.findByUsername(address)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(principal)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByUserName_nonExistentAccountEmail_none() {
|
||||
EmailAddress email = unusedEmail;
|
||||
|
||||
when(accountServ.getAccount(email)).thenReturn(none());
|
||||
|
||||
serv.findByUsername(email.address())
|
||||
.as(StepVerifier::create)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.config.DatabaseConfiguration;
|
||||
import eu.bitfield.recipes.core.profile.ProfileRepository;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.AsyncLayerFactory;
|
||||
import eu.bitfield.recipes.test.data.AsyncRootStorage;
|
||||
import eu.bitfield.recipes.util.Transaction;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.reactive.TransactionalOperator;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.AsyncPersistence.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.To.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Import(DatabaseConfiguration.class) @DataR2dbcTest
|
||||
class AccountRepositoryTest implements AccountQueries {
|
||||
|
||||
final AccountRepository repo;
|
||||
|
||||
final @Delegate AsyncRootStorage rootStorage;
|
||||
final @Delegate ProfileLayer profileLayer;
|
||||
final @Delegate AccountLayer accountLayer;
|
||||
final Transaction transaction;
|
||||
|
||||
@Autowired
|
||||
public AccountRepositoryTest(
|
||||
ProfileRepository profileRepo,
|
||||
AccountRepository accountRepo,
|
||||
TransactionalOperator transactionalOperator)
|
||||
{
|
||||
this.repo = accountRepo;
|
||||
this.rootStorage = new AsyncRootStorage();
|
||||
var layers = new AsyncLayerFactory(rootStorage);
|
||||
this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo));
|
||||
this.accountLayer = layers.accountLayer(asyncPersistence(accountRepo));
|
||||
this.transaction = new Transaction(transactionalOperator);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAccount_validInitialAccount_savedAccount() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
@NoArgsConstructor @AllArgsConstructor @Setter @Accessors(fluent = true)
|
||||
class Check {
|
||||
Account initial, actual, saved;
|
||||
}
|
||||
|
||||
var checks = supply(() -> new Check().initial(account.initial()))
|
||||
.zipWhen((Check c) -> repo.addAccount(c.initial), Check::actual)
|
||||
.zipWhen((Check c) -> repo.findById(c.actual.id()), Check::saved);
|
||||
|
||||
init(slot(account))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext((Check c) -> {
|
||||
assertThat(c.actual.id()).isNotZero();
|
||||
assertThat(c.actual).usingRecursiveComparison().ignoringFields(Account.Fields.id).isEqualTo(c.initial);
|
||||
assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAccount_invalidAccountNonExistentProfileId_errorForeignKeyConstraintViolation() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
|
||||
var checks = defer(() -> repo.addAccount(account.initial()));
|
||||
|
||||
invalidate(account.profile());
|
||||
init(slot(account))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.verifyError(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountByEmail_usedAccountEmail_account() {
|
||||
List<AccountSlot> accounts = accountSlots().limit(2).map(to::blank).toList();
|
||||
AccountSlot account = accounts.getFirst();
|
||||
|
||||
var checks = defer(() -> repo.accountByEmail(account.email()))
|
||||
.zipWhen((Account actual) -> some(account.saved()));
|
||||
|
||||
save(slots(accounts))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext(consumer((Account actual, Account saved) -> {
|
||||
assertThat(actual).usingRecursiveComparison().isEqualTo(saved);
|
||||
}))
|
||||
.verifyComplete();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountByEmail_unusedEmail_none() {
|
||||
var checks = repo.accountByEmail(unusedEmail);
|
||||
|
||||
checks.as(transaction::rollbackVerify)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isEmailUsed_usedAccountEmail_true() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
|
||||
var checks = defer(() -> repo.isEmailUsed(account.email()));
|
||||
|
||||
save(slot(account))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isEmailUsed_unusedAccountEmail_false() {
|
||||
var checks = repo.isEmailUsed(unusedEmail);
|
||||
|
||||
checks.as(transaction::rollbackVerify)
|
||||
.expectNext(false)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package eu.bitfield.recipes.core.account;
|
||||
|
||||
import eu.bitfield.recipes.auth.email.EmailAddress;
|
||||
import eu.bitfield.recipes.test.core.account.AccountLayer;
|
||||
import eu.bitfield.recipes.test.core.account.AccountQueries;
|
||||
import eu.bitfield.recipes.test.core.account.AccountSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.data.LayerFactory;
|
||||
import eu.bitfield.recipes.test.data.RootStorage;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
public class AccountServiceTest implements AccountQueries {
|
||||
AccountRepository repo = mock(AccountRepository.class);
|
||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
||||
AccountService serv = new AccountService(repo, encoder);
|
||||
|
||||
@Delegate RootStorage rootStorage = new RootStorage();
|
||||
LayerFactory layers = new LayerFactory(rootStorage);
|
||||
@Delegate ProfileLayer profileLayer = layers.profileLayer();
|
||||
@Delegate AccountLayer accountLayer = layers.accountLayer();
|
||||
|
||||
@Test
|
||||
public void addAccount_existentProfileId_savedAccount() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
|
||||
when(encoder.encode(account.passwordIn().raw())).thenReturn(account.password().encoded());
|
||||
when(repo.addAccount(account.initial())).thenReturn(some(account.saved()));
|
||||
|
||||
serv.addAccount(account.profile().id(), account.email(), account.password())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(account.saved())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void getAccount_existentAccountEmail_account() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
|
||||
when(repo.accountByEmail(account.email())).thenReturn(some(account.saved()));
|
||||
|
||||
serv.getAccount(account.email())
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(account.saved()).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkEmailAvailable_usedEmail_errorEmailAlreadyInUse() {
|
||||
AccountSlot account = accountSlot().blank();
|
||||
save(slot(account));
|
||||
|
||||
when(repo.isEmailUsed(account.email())).thenReturn(some(true));
|
||||
|
||||
serv.checkEmail(account.email())
|
||||
.as(StepVerifier::create)
|
||||
.verifyError(AccountService.EmailAlreadyInUse.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkEmailAvailable_unusedEmail_none() {
|
||||
EmailAddress email = unusedEmail;
|
||||
|
||||
when(repo.isEmailUsed(email)).thenReturn(some(false));
|
||||
|
||||
serv.checkEmail(email)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(email)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package eu.bitfield.recipes.core.category;
|
||||
|
||||
import eu.bitfield.recipes.config.DatabaseConfiguration;
|
||||
import eu.bitfield.recipes.core.link.LinkRecCatRepository;
|
||||
import eu.bitfield.recipes.core.profile.ProfileRepository;
|
||||
import eu.bitfield.recipes.core.recipe.RecipeRepository;
|
||||
import eu.bitfield.recipes.test.core.category.CategoryLayer;
|
||||
import eu.bitfield.recipes.test.core.category.CategorySlot;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatQueries;
|
||||
import eu.bitfield.recipes.test.core.link.LinkRecCatSlot;
|
||||
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
|
||||
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
|
||||
import eu.bitfield.recipes.test.data.AsyncLayerFactory;
|
||||
import eu.bitfield.recipes.test.data.AsyncRootStorage;
|
||||
import eu.bitfield.recipes.util.Transaction;
|
||||
import lombok.experimental.Delegate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.reactive.TransactionalOperator;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static eu.bitfield.recipes.test.AsyncPersistence.*;
|
||||
import static eu.bitfield.recipes.test.data.EntitySlots.*;
|
||||
import static eu.bitfield.recipes.util.AsyncUtils.*;
|
||||
import static eu.bitfield.recipes.util.To.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static reactor.function.TupleUtils.*;
|
||||
|
||||
@Import(DatabaseConfiguration.class) @DataR2dbcTest
|
||||
class CategoryRepositoryTest implements LinkRecCatQueries {
|
||||
final CategoryRepository repo;
|
||||
|
||||
final @Delegate AsyncRootStorage rootStorage;
|
||||
final @Delegate ProfileLayer profileLayer;
|
||||
final @Delegate RecipeLayer recipeLayer;
|
||||
final @Delegate CategoryLayer categoryLayer;
|
||||
final @Delegate LinkRecCatLayer linkLayer;
|
||||
final Transaction transaction;
|
||||
|
||||
@Autowired
|
||||
public CategoryRepositoryTest(
|
||||
ProfileRepository profileRepo,
|
||||
CategoryRepository categoryRepo,
|
||||
RecipeRepository recipeRepo,
|
||||
LinkRecCatRepository linkRepo,
|
||||
TransactionalOperator transactionalOp)
|
||||
{
|
||||
this.repo = categoryRepo;
|
||||
this.rootStorage = new AsyncRootStorage();
|
||||
var layerFactory = new AsyncLayerFactory(rootStorage);
|
||||
this.profileLayer = layerFactory.profileLayer(asyncPersistence(profileRepo));
|
||||
this.categoryLayer = layerFactory.categoryLayer(asyncPersistence(categoryRepo));
|
||||
this.recipeLayer = layerFactory.recipeLayer(asyncPersistence(recipeRepo));
|
||||
this.linkLayer = layerFactory.linkRecCatLayer(asyncPersistence(linkRepo));
|
||||
this.transaction = new Transaction(transactionalOp);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addCategory_validInitialCategory_savedCategory() {
|
||||
CategorySlot category = categorySlot().blank();
|
||||
|
||||
var checks = defer(() -> repo.addCategory(category.initial()))
|
||||
.zipWhen((Category actual) -> repo.findById(actual.id()));
|
||||
|
||||
init(slot(category))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext(consumer((Category actual, Category saved) -> {
|
||||
Category initial = category.initial();
|
||||
assertThat(actual.id()).isNotZero();
|
||||
assertThat(actual).usingRecursiveComparison().ignoringFields(Category.Fields.id).isEqualTo(initial);
|
||||
assertThat(actual).usingRecursiveComparison().isEqualTo(saved);
|
||||
}))
|
||||
.verifyComplete();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void addCategory_initialCategoryExistentName_errorUniqueNameConstraintException() {
|
||||
CategorySlot category = categorySlot().blank();
|
||||
|
||||
var checks = defer(() -> repo.addCategory(category.initial()));
|
||||
|
||||
save(slot(category))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.verifyError(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void categoriesOutByRecipeId_existentRecipeId_categoriesOut() {
|
||||
List<RecipeSlot> recipes = recipeSlotsWithLinkCounts(2, 1).map(to::blank).toList();
|
||||
RecipeSlot recipe = recipes.getFirst();
|
||||
List<CategorySlot> categories = linkedCategorySlots(recipe).map(to::blank).toList();
|
||||
List<LinkRecCatSlot> allLinks = recipes.stream().flatMap(this::linkRecCatSlots).map(to::blank).toList();
|
||||
|
||||
var checks = defer(() -> repo.categoriesByRecipeId(recipe.id()).collectList());
|
||||
|
||||
save(slots(allLinks))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext((List<Category> actual) -> {
|
||||
List<Category> saved = categories.stream().map(to::saved).toList();
|
||||
assertThat(actual).containsExactlyInAnyOrderElementsOf(saved);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void categoryIdByName_existentCategoryName_categoryId() {
|
||||
List<CategorySlot> categories = categorySlots().limit(2).map(to::blank).toList();
|
||||
CategorySlot category = categories.getFirst();
|
||||
|
||||
var checks = defer(() -> repo.categoryIdByName(category.name()));
|
||||
|
||||
save(slot(category))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext((Long actualId) -> {
|
||||
assertThat(actualId).isEqualTo(category.id());
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void categoryIdByName_nonExistentCategoryName_none() {
|
||||
var checks = repo.categoryIdByName("not_a_category");
|
||||
|
||||
checks.as(transaction::rollbackVerify)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -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<CategorySlot> categories = categorySlots().map(to::blank).toList();
|
||||
save(slots(categories));
|
||||
List<Category> saved = categories.stream().map(to::saved).toList();
|
||||
List<CategoryIn> 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<Category> allActual) -> {
|
||||
assertThat(allActual).containsExactlyElementsOf(saved);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCategories_existentRecipeId_linkedCategoriesOut() {
|
||||
RecipeSlot recipe = recipeSlot().blank();
|
||||
List<LinkRecCatSlot> links = linkRecCatSlots(recipe).map(to::blank).toList();
|
||||
List<CategorySlot> categories = linkedCategorySlots(recipe).map(to::blank).toList();
|
||||
save(slots(links));
|
||||
List<Category> saved = categories.stream().map(to::saved).toList();
|
||||
|
||||
when(repo.categoriesByRecipeId(recipe.id())).thenReturn(flux(saved));
|
||||
|
||||
serv.getCategories(recipe.id())
|
||||
.as(StepVerifier::create)
|
||||
.assertNext((List<Category> actual) -> {
|
||||
assertThat(actual).containsExactlyElementsOf(saved);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -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<IngredientSlot> 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<Ingredient> allInitial) -> repo.addIngredients(allInitial).collectList())
|
||||
.flatMapMany(function((List<Ingredient> allInitial, List<Ingredient> 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<Check> 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<RecipeIngredientGroup> groups = recipeIngredientGroupsWithIngredientCounts(2, 1);
|
||||
List<IngredientSlot> allIngredients = groups.stream()
|
||||
.flatMap(RecipeIngredientGroup::ingredients)
|
||||
.map(to::blank)
|
||||
.toList();
|
||||
RecipeSlot recipe = groups.getFirst().recipe().blank();
|
||||
List<IngredientSlot> 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<Ingredient> actual) -> {
|
||||
List<Ingredient> 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<RecipeIngredientGroup> groups = recipeIngredientGroupsWithIngredientCounts(2, 1);
|
||||
List<IngredientSlot> 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<Ingredient> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<IngredientSlot> ingredients = group.ingredients().map(to::blank).toList();
|
||||
save(slot(recipe).add(ingredients));
|
||||
List<Ingredient> initial = ingredients.stream().map(to::initial).toList();
|
||||
List<Ingredient> saved = ingredients.stream().map(to::saved).toList();
|
||||
List<IngredientIn> savedIn = ingredients.stream().map(toIngredientIn).toList();
|
||||
|
||||
when(repo.addIngredients(initial)).thenReturn(flux(saved));
|
||||
|
||||
serv.addIngredients(recipe.id(), savedIn)
|
||||
.as(StepVerifier::create)
|
||||
.assertNext((List<Ingredient> actual) -> {
|
||||
assertThat(actual).containsExactlyElementsOf(saved);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void getIngredients_existentRecipeId_ingredients() {
|
||||
RecipeIngredientGroup group = recipeIngredientGroupWithIngredientCount(5);
|
||||
RecipeSlot recipe = group.recipe().blank();
|
||||
List<IngredientSlot> ingredients = group.ingredients().map(to::blank).toList();
|
||||
save(slot(recipe).add(ingredients));
|
||||
List<Ingredient> saved = ingredients.stream().map(to::saved).toList();
|
||||
|
||||
when(repo.ingredientsByRecipeId(recipe.id())).thenReturn(flux(saved));
|
||||
|
||||
serv.getIngredients(recipe.id())
|
||||
.as(StepVerifier::create)
|
||||
.assertNext((List<Ingredient> actual) -> {
|
||||
assertThat(actual).containsExactlyElementsOf(saved);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateIngredients_newIngredients_updatedIngredients() {
|
||||
RecipeIngredientGroup group = recipeIngredientGroupWithIngredientCount(5);
|
||||
RecipeSlot recipe = group.recipe().blank();
|
||||
List<IngredientSlot> ingredients = group.ingredients().map(to::blank).toList();
|
||||
save(slot(recipe).add(ingredients));
|
||||
List<Ingredient> initial = ingredients.stream().map(to::initial).toList();
|
||||
List<Ingredient> saved = ingredients.stream().map(to::saved).toList();
|
||||
List<IngredientIn> 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<Ingredient> actual) -> {
|
||||
assertThat(actual).containsExactlyElementsOf(saved);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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<LinkRecCatSlot> 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<LinkRecCat> allInitial) -> repo.addLinks(allInitial).collectList())
|
||||
.flatMapMany(function((List<LinkRecCat> allInitial, List<LinkRecCat> 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<Check> 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<RecipeSlot> recipes = recipeSlotsWithLinkCounts(2, 1).map(to::blank).toList();
|
||||
RecipeSlot recipeForRemoval = recipes.getFirst();
|
||||
List<LinkRecCatSlot> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<LinkRecCatSlot> links = linkRecCatSlots(recipe).map(to::blank).toList();
|
||||
save(slots(links));
|
||||
List<Category> categories = links.stream().map(LinkRecCatSlot::category).map(to::saved).toList();
|
||||
List<LinkRecCat> initial = links.stream().map(to::initial).toList();
|
||||
List<LinkRecCat> saved = links.stream().map(to::saved).toList();
|
||||
|
||||
when(repo.addLinks(initial)).thenReturn(flux(saved));
|
||||
|
||||
serv.addLinks(recipe.id(), categories)
|
||||
.as(StepVerifier::create)
|
||||
.assertNext((List<LinkRecCat> actual) -> {
|
||||
assertThat(actual).containsExactlyElementsOf(saved);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateLinks_newExistentRecipeCategories_updatedLinkRecCats() {
|
||||
RecipeSlot recipe = recipeSlotWithLinkCount(1).blank();
|
||||
List<LinkRecCatSlot> links = linkRecCatSlots(recipe).map(to::blank).toList();
|
||||
save(slots(links));
|
||||
List<Category> categories = links.stream().map(LinkRecCatSlot::category).map(to::saved).toList();
|
||||
List<LinkRecCat> initial = links.stream().map(to::initial).toList();
|
||||
List<LinkRecCat> 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<LinkRecCat> actual) -> {
|
||||
assertThat(actual).containsExactlyElementsOf(saved);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<RecipeSlot> 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<Recipe> 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<Recipe> 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<CategorySlot> allCategories = categorySlotsWithLinkCounts(2, 1, 0).map(to::blank).toList();
|
||||
Map<CategorySlot, List<RecipeSlot>> recipesByCategory = allCategories.stream()
|
||||
.map((CategorySlot category) -> Pair.of(category, linkedRecipeSlots(category).map(to::blank).toList()))
|
||||
.collect(Pair.toMap());
|
||||
List<LinkRecCatSlot> 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<CategorySlot> 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<Long> actualRecipeIds) -> savedRecipeIds);
|
||||
})
|
||||
.collectList();
|
||||
|
||||
save(slots(links).add(allCategories))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext(thatAllSatisfy(consumer((List<Long> actualRecipeIds, List<Long> savedRecipeIds) -> {
|
||||
assertThat(actualRecipeIds).containsExactlyElementsOf(savedRecipeIds);
|
||||
})))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeIds_noCategoryName$someRecipeName_recipeIds() {
|
||||
List<RecipeSlot> 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<Long> actualRecipeIds) -> savedRecipeIds);
|
||||
})
|
||||
.collectList();
|
||||
save(slots(allRecipes))
|
||||
.then(checks)
|
||||
.as(transaction::rollbackVerify)
|
||||
.assertNext(thatAllSatisfy(consumer((List<Long> actualRecipeIds, List<Long> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<RecipeSlot> recipes = linkedRecipeSlots(category).map(to::blank).toList();
|
||||
save(slot(category).add(recipes));
|
||||
List<Long> 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<Long> actualIds) -> {
|
||||
assertThat(actualIds).containsExactlyElementsOf(savedIds);
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void findRecipeIds_recipeNamePart_recipeIds() {
|
||||
String recipeName = "minestro";
|
||||
List<RecipeSlot> recipes = recipeSlots().map(to::blank).toList();
|
||||
save(slots(recipes));
|
||||
Stream<RecipeSlot> matchingRecipes = recipes.stream().filter(containsRecipeName(recipeName));
|
||||
List<Long> 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<Long> 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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user