feat!: initial prototype

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

View File

@@ -0,0 +1,12 @@
package eu.bitfield.recipes;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RecipesApplication {
public static void main(String[] args) {
SpringApplication.run(RecipesApplication.class, args);
}
}

View File

@@ -0,0 +1,113 @@
package eu.bitfield.recipes.api;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.core.MethodParameter;
import org.springframework.http.ProblemDetail;
import org.springframework.util.MultiValueMap;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.ErrorResponse;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.server.ServerWebInputException;
import java.lang.reflect.Executable;
import java.lang.reflect.Parameter;
import java.util.List;
import static eu.bitfield.recipes.util.CollectionUtils.*;
import static org.springframework.http.HttpStatus.*;
public interface ErrorResponseHandling {
Logger log = LoggerFactory.getLogger(ErrorResponseHandling.class);
private static void addMethodParameter(ProblemDetail problemDetail, MethodParameter methodParam) {
Executable executable = methodParam.getExecutable();
Parameter param = methodParam.getParameter();
String method = executable.toGenericString();
String argument = param.getType().getCanonicalName() + " " + param.getName();
problemDetail.setProperty("method", method);
problemDetail.setProperty("argument", argument);
}
@ExceptionHandler
default ErrorResponse handleError(HandlerMethodValidationException e) {
log.debug("""
Handling {}:
method: {}
errors: {}""",
e.getClass().getName(), e.getMethod(), e.getAllErrors(), e);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
String method = e.getMethod().toGenericString();
problemDetail.setProperty("method", method);
MultiValueMap<String, String> errors = multiValueMap();
for (ParameterValidationResult result : e.getParameterValidationResults()) {
Parameter param = result.getMethodParameter().getParameter();
String errorKey = param.getName();
List<String> errorValues = result.getResolvableErrors()
.stream()
.map(MessageSourceResolvable::getDefaultMessage)
.toList();
errors.addAll(errorKey, errorValues);
}
problemDetail.setProperty("errors", errors);
return ErrorResponse.builder(e, problemDetail).build();
}
@ExceptionHandler
default ErrorResponse handleError(WebExchangeBindException e) {
List<ObjectError> objectErrors = e.getAllErrors();
log.debug("""
Handling {}:
methodParameter: {}
errors: {}""",
e.getClass().getName(), e.getMethodParameter(), e.getAllErrors(), e);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
MethodParameter methodParam = e.getMethodParameter();
if (methodParam != null) addMethodParameter(problemDetail, methodParam);
MultiValueMap<String, String> errors = multiValueMap();
for (ObjectError objectError : objectErrors) {
String errorKey = objectError.getObjectName();
if (objectError instanceof FieldError fieldError) {
errorKey += "." + fieldError.getField();
}
errors.add(errorKey, objectError.getDefaultMessage());
}
problemDetail.setProperty("errors", errors);
return ErrorResponse.builder(e, problemDetail).build();
}
@ExceptionHandler
default ErrorResponse handleError(ServerWebInputException e) {
log.debug("""
Handling {}:
methodParameter: {}""",
e.getClass().getName(), e.getMethodParameter(), e);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason());
MethodParameter methodParam = e.getMethodParameter();
if (methodParam != null) addMethodParameter(problemDetail, methodParam);
return ErrorResponse.builder(e, problemDetail).build();
}
@ExceptionHandler
default ErrorResponse handleError(ErrorResponseException e) {
log.debug("Handling {}", e.getClass().getName(), e);
return e;
}
}

View File

@@ -0,0 +1,29 @@
package eu.bitfield.recipes.api.account;
import eu.bitfield.recipes.api.ErrorResponseHandling;
import eu.bitfield.recipes.core.account.AccountIn;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import static eu.bitfield.recipes.util.ErrorUtils.*;
import static org.springframework.http.HttpStatus.*;
@RequiredArgsConstructor
@RestController @RequestMapping("/api/account")
public class AccountEndpoint implements ErrorResponseHandling {
private final RegisterAccount registerOp;
@PostMapping("/register")
public Mono<Void> registerAccount(@RequestBody @Valid AccountIn accountIn) {
return registerOp.registerAccount(accountIn)
.onErrorMap(RegisterAccount.EmailAlreadyInUse.class,
e -> errorResponseException(e, BAD_REQUEST))
.then();
}
}

View File

@@ -0,0 +1,48 @@
package eu.bitfield.recipes.api.account;
import eu.bitfield.recipes.auth.email.EmailAddress;
import eu.bitfield.recipes.auth.password.Password;
import eu.bitfield.recipes.core.account.Account;
import eu.bitfield.recipes.core.account.AccountIn;
import eu.bitfield.recipes.core.account.AccountService;
import eu.bitfield.recipes.core.profile.Profile;
import eu.bitfield.recipes.core.profile.ProfileService;
import eu.bitfield.recipes.view.registration.RegistrationView;
import lombok.RequiredArgsConstructor;
import org.springframework.core.NestedRuntimeException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static reactor.function.TupleUtils.*;
@Service
@RequiredArgsConstructor
public class RegisterAccount {
private final ProfileService profileServ;
private final AccountService accountServ;
@Transactional
public Mono<RegistrationView> registerAccount(AccountIn accountIn) {
return Mono.defer(() -> {
return accountServ.checkEmail(EmailAddress.of(accountIn.email()))
.onErrorMap(AccountService.EmailAlreadyInUse.class, EmailAlreadyInUse::new);
})
.zipWhen((EmailAddress __) -> profileServ.addProfile())
.flatMap(function((EmailAddress email, Profile profile) -> {
return some(accountIn.password())
.map(accountServ::encode)
.flatMap((Password password) -> accountServ.addAccount(profile.id(), email, password))
.map((Account account) -> new RegistrationView(profile, account));
}));
}
public static class EmailAlreadyInUse extends Error {
public EmailAlreadyInUse(AccountService.EmailAlreadyInUse e) {super(e);}
}
public static class Error extends NestedRuntimeException {
public Error(Throwable cause) {super("account registration failed", cause);}
}
}

View File

@@ -0,0 +1,43 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.category.CategoryService;
import eu.bitfield.recipes.core.ingredient.IngredientService;
import eu.bitfield.recipes.core.link.LinkRecCatService;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.core.step.StepService;
import eu.bitfield.recipes.util.Chronology;
import eu.bitfield.recipes.view.recipe.RecipeView;
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import static reactor.core.publisher.Mono.*;
import static reactor.function.TupleUtils.*;
@Service
@RequiredArgsConstructor
public class AddRecipe {
private final RecipeService recipeServ;
private final CategoryService categoryServ;
private final LinkRecCatService linkServ;
private final IngredientService ingredientServ;
private final StepService stepServ;
private final Chronology time;
@Transactional
public Mono<RecipeView> addRecipe(RecipeViewIn detailsIn, long profileId) {
return defer(() -> recipeServ.addRecipe(profileId, detailsIn.recipe(), time.now()))
.zipWith(categoryServ.addCategories(detailsIn.categories()))
.flatMap(function((recipe, category) -> {
return zip(linkServ.addLinks(recipe.id(), category),
ingredientServ.addIngredients(recipe.id(), detailsIn.ingredients()),
stepServ.addSteps(recipe.id(), detailsIn.steps()))
.map(function((links, ingredients, steps) -> {
return new RecipeView(recipe, category, links, ingredients, steps);
}));
}));
}
}

View File

@@ -0,0 +1,30 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.util.Pagination;
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import java.util.List;
@Service
@RequiredArgsConstructor
public class FindRecipes {
private final RecipeService recipeServ;
private final GetRecipe getRecipe;
@Transactional(readOnly = true)
public Mono<List<RecipeViewOut>> findRecipes(
@Nullable String categoryName,
@Nullable String recipeName,
Pagination pagination)
{
return recipeServ.findRecipeIds(categoryName, recipeName, pagination)
.flatMapSequential(getRecipe::getRecipe)
.collectList(); // collect to close transaction on completion
}
}

View File

@@ -0,0 +1,48 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.category.CategoryService;
import eu.bitfield.recipes.core.ingredient.IngredientService;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
import eu.bitfield.recipes.core.step.StepService;
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
import lombok.RequiredArgsConstructor;
import org.springframework.core.NestedRuntimeException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import static eu.bitfield.recipes.view.recipe.RecipeView.*;
import static reactor.core.publisher.Mono.*;
import static reactor.function.TupleUtils.*;
@Service
@RequiredArgsConstructor
public class GetRecipe {
private final RecipeService recipeServ;
private final CategoryService categoryServ;
private final IngredientService ingredientServ;
private final StepService stepServ;
@Transactional(readOnly = true)
public Mono<RecipeViewOut> getRecipe(long recipeId) {
return defer(() -> recipeServ.getRecipe(recipeId).onErrorMap(RecipeNotFound.class, NotFound::new))
.flatMap(recipe -> {
return zip(categoryServ.getCategories(recipeId),
ingredientServ.getIngredients(recipeId),
stepServ.getSteps(recipeId))
.map(function((categories, ingredients, steps) -> {
return createRecipeViewOut(recipe, categories, ingredients, steps);
}));
});
}
public static class NotFound extends Error {
public NotFound(RecipeNotFound recipeNotFound) {super(recipeNotFound);}
}
public static class Error extends NestedRuntimeException {
public Error(Throwable cause) {super("recipe details retrieval failed", cause);}
}
}

View File

@@ -0,0 +1,81 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.api.ErrorResponseHandling;
import eu.bitfield.recipes.auth.ProfileIdentityAccess;
import eu.bitfield.recipes.util.Pagination;
import eu.bitfield.recipes.view.recipe.RecipeView;
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static eu.bitfield.recipes.util.ErrorUtils.*;
import static java.util.function.Function.*;
import static org.springframework.http.HttpStatus.*;
@RequiredArgsConstructor
@RestController @RequestMapping("/api/recipe")
public class RecipeEndpoint implements ErrorResponseHandling {
public static final long MIN_LIMIT = 1;
public static final long MAX_LIMIT = 100;
private final AddRecipe addOp;
private final GetRecipe getOp;
private final UpdateRecipe updateOp;
private final RemoveRecipe removeOp;
private final FindRecipes findOp;
private final ProfileIdentityAccess profile;
@PostMapping("/new")
public Mono<RecipeViewOut> addRecipe(@RequestBody @Valid RecipeViewIn recipeViewIn) {
return profile.id()
.flatMap(profileId -> addOp.addRecipe(recipeViewIn, profileId))
.map(RecipeView::toRecipeViewOut);
}
@GetMapping("/{recipeId}")
public Mono<RecipeViewOut> getRecipe(@PathVariable long recipeId) {
return getOp.getRecipe(recipeId)
.onErrorMap(GetRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND));
}
@PutMapping("/{recipeId}")
public Mono<RecipeViewOut> updateRecipe(@PathVariable long recipeId,
@RequestBody @Valid RecipeViewIn recipeViewIn)
{
return profile.id()
.flatMap(profileId -> updateOp.updateRecipe(recipeId, recipeViewIn, profileId))
.onErrorMap(UpdateRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND))
.onErrorMap(UpdateRecipe.Forbidden.class, e -> errorResponseException(e, FORBIDDEN))
.map(RecipeView::toRecipeViewOut);
}
@DeleteMapping("/{recipeId}")
public Mono<Void> removeRecipe(@PathVariable long recipeId) {
return profile.id()
.flatMap(profileId -> removeOp.removeRecipe(recipeId, profileId))
.onErrorMap(RemoveRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND))
.onErrorMap(RemoveRecipe.Forbidden.class, e -> errorResponseException(e, FORBIDDEN))
.then();
}
@GetMapping("/search")
public Flux<RecipeViewOut> findRecipes(
@RequestParam(name = "category", required = false) @Nullable String categoryName,
@RequestParam(name = "recipe", required = false) @Nullable String recipeName,
@RequestParam(name = "limit") @Min(MIN_LIMIT) @Max(MAX_LIMIT) @Valid long limit,
@RequestParam(name = "offset") @PositiveOrZero @Valid long offset)
{
return defer(() -> findOp.findRecipes(categoryName, recipeName, new Pagination(limit, offset)))
.flatMapIterable(identity());
}
}

View File

@@ -0,0 +1,36 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden;
import lombok.RequiredArgsConstructor;
import org.springframework.core.NestedRuntimeException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
@RequiredArgsConstructor
@Service
public class RemoveRecipe {
private final RecipeService recipeService;
@Transactional
public Mono<Boolean> removeRecipe(long recipeId, long profileId) {
return recipeService.removeRecipe(recipeId, profileId)
.onErrorMap(RecipeNotFound.class, NotFound::new)
.onErrorMap(RemoveRecipeForbidden.class, Forbidden::new);
}
public static final class NotFound extends Error {
public NotFound(RecipeNotFound recipeNotFound) {super(recipeNotFound);}
}
public static class Forbidden extends Error {
public Forbidden(RemoveRecipeForbidden forbidden) {super(forbidden);}
}
public static class Error extends NestedRuntimeException {
public Error(Throwable cause) {super("recipe details removal failed", cause);}
}
}

View File

@@ -0,0 +1,67 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.category.CategoryService;
import eu.bitfield.recipes.core.ingredient.IngredientService;
import eu.bitfield.recipes.core.link.LinkRecCatService;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden;
import eu.bitfield.recipes.core.step.StepService;
import eu.bitfield.recipes.util.Chronology;
import eu.bitfield.recipes.view.recipe.RecipeView;
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
import lombok.RequiredArgsConstructor;
import org.springframework.core.NestedRuntimeException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import static reactor.core.publisher.Mono.*;
import static reactor.function.TupleUtils.*;
@Service
@RequiredArgsConstructor
public class UpdateRecipe {
private final RecipeService recipeServ;
private final CategoryService categoryServ;
private final LinkRecCatService linkServ;
private final StepService stepServ;
private final IngredientService ingredientServ;
private final Chronology time;
@Transactional
public Mono<RecipeView> updateRecipe(long recipeId, RecipeViewIn detailsIn, long profileId) {
return Mono.defer(() -> {
return recipeServ.updateRecipe(recipeId, profileId, detailsIn.recipe(), time.now())
.onErrorMap(RecipeNotFound.class, NotFound::new)
.onErrorMap(UpdateRecipeForbidden.class, Forbidden::new);
})
.zipWhen(__ -> categoryServ.addCategories(detailsIn.categories()))
.flatMap(function((recipe, categories) -> {
return zip(linkServ.updateLinks(recipeId, categories),
stepServ.updateSteps(recipeId, detailsIn.steps()),
ingredientServ.updateIngredients(recipeId, detailsIn.ingredients()))
.map(function((links, steps, ingredients) -> {
return new RecipeView(recipe, categories, links, ingredients, steps);
}));
}));
}
public static class NotFound extends Error {
public NotFound(RecipeNotFound recipeNotFound) {
super(recipeNotFound);
}
}
public static class Forbidden extends Error {
public Forbidden(UpdateRecipeForbidden forbidden) {
super(forbidden);
}
}
public static class Error extends NestedRuntimeException {
public Error(Throwable cause) {
super("recipe details update failed", cause);
}
}
}

View File

@@ -0,0 +1,18 @@
package eu.bitfield.recipes.auth;
import eu.bitfield.recipes.core.account.Account;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.springframework.security.core.userdetails.User;
import java.util.List;
@Getter @Accessors(fluent = true)
public class AccountPrincipal extends User implements ProfileIdentity {
private final long profileId;
public AccountPrincipal(Account account) {
super(account.email(), account.passwordEncoded(), List.of());
this.profileId = account.profileId();
}
}

View File

@@ -0,0 +1,28 @@
package eu.bitfield.recipes.auth;
import eu.bitfield.recipes.auth.email.EmailAddress;
import eu.bitfield.recipes.auth.email.EmailAddressIn;
import eu.bitfield.recipes.core.account.AccountService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import static eu.bitfield.recipes.util.AsyncUtils.*;
@RequiredArgsConstructor
@Service @Transactional(readOnly = true)
public class AccountPrincipalService implements ReactiveUserDetailsService {
private final AccountService accountServ;
@Override
public Mono<UserDetails> findByUsername(String address) {
return some(address)
.mapNotNull(EmailAddressIn::of)
.map(EmailAddress::of)
.flatMap(accountServ::getAccount)
.map(AccountPrincipal::new);
}
}

View File

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

View File

@@ -0,0 +1,18 @@
package eu.bitfield.recipes.auth;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
public class ProfileIdentityAccess {
public Mono<Long> id() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getPrincipal)
.cast(ProfileIdentity.class)
.map(ProfileIdentity::profileId);
}
}

View File

@@ -0,0 +1,28 @@
package eu.bitfield.recipes.auth.email;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.springframework.lang.Nullable;
import java.util.Comparator;
import static java.util.Comparator.*;
@Getter @Accessors(fluent = true) @EqualsAndHashCode
public class EmailAddress implements Comparable<EmailAddress> {
public static Comparator<EmailAddress> order = comparing(EmailAddress::address);
private final String address;
private EmailAddress(String address) {
this.address = address;
}
public static EmailAddress of(EmailAddressIn emailIn) {
return new EmailAddress(emailIn.address().toLowerCase());
}
public int compareTo(@Nullable EmailAddress other) {
return order.compare(this, other);
}
}

View File

@@ -0,0 +1,45 @@
package eu.bitfield.recipes.auth.email;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.springframework.lang.Nullable;
import java.util.Comparator;
import java.util.regex.Pattern;
import static java.util.Comparator.*;
@Getter @Accessors(fluent = true) @EqualsAndHashCode
public class EmailAddressIn implements Comparable<EmailAddressIn> {
private static final String EMAIL_REGEX =
"^[\\p{Alnum}!#$%&'*+/=?^_`{|}~-]+(?:\\.[\\p{Alnum}!#$%&'*+/=?^_`{|}~-]+)*@" +
"\\p{Alnum}(?:[\\p{Alnum}-]{0,61}\\p{Alnum})?(?:\\.\\p{Alnum}(?:[\\p{Alnum}-]{0,61}\\p{Alnum})?)+$";
private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);
// based on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation/test
public static Comparator<EmailAddressIn> order = comparing(EmailAddressIn::address);
private final @JsonValue @NotNull @Email(regexp = EMAIL_REGEX) String address;
@JsonCreator
private EmailAddressIn(String address) {
this.address = address;
}
public static @Nullable EmailAddressIn of(String address) {
if (address == null || !EMAIL_PATTERN.matcher(address).matches()) return null;
return new EmailAddressIn(address);
}
public static EmailAddressIn ofUnchecked(String address) {
return new EmailAddressIn(address);
}
public int compareTo(@Nullable EmailAddressIn other) {
return order.compare(this, other);
}
}

View File

@@ -0,0 +1,36 @@
package eu.bitfield.recipes.auth.password;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.springframework.lang.Nullable;
import java.util.Comparator;
import static java.util.Comparator.*;
@Getter @Accessors(fluent = true) @EqualsAndHashCode
public class Password implements Comparable<Password> {
public static Comparator<Password> order = comparing(Password::encoded);
private final String encoded;
private Password(String encoded) {
this.encoded = encoded;
}
public static Password of(Encoder encoder, PasswordIn passwordIn) {
return new Password(encoder.encode(passwordIn));
}
public static Password ofUnchecked(String encoded) {
return new Password(encoded);
}
public int compareTo(@Nullable Password other) {
return order.compare(this, other);
}
public interface Encoder {
String encode(PasswordIn password);
}
}

View File

@@ -0,0 +1,34 @@
package eu.bitfield.recipes.auth.password;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.springframework.lang.Nullable;
import java.util.Comparator;
import static java.util.Comparator.*;
@Getter @Accessors(fluent = true) @EqualsAndHashCode
public class PasswordIn implements Comparable<PasswordIn> {
public static final int MIN_PASSWORD_LENGTH = 8;
public static Comparator<PasswordIn> order = comparing(PasswordIn::raw);
private final @JsonValue @NotNull @Size(min = MIN_PASSWORD_LENGTH) String raw;
@JsonCreator
private PasswordIn(String raw) {
this.raw = raw;
}
public static PasswordIn ofUnchecked(String raw) {
return new PasswordIn(raw);
}
public int compareTo(@Nullable PasswordIn other) {
return order.compare(this, other);
}
}

View File

@@ -0,0 +1,28 @@
package eu.bitfield.recipes.config;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
import java.util.stream.Stream;
@Configuration
public class CacheConfiguration {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(caches());
return cacheManager;
}
List<Cache> caches() {
return Stream.of("account")
.map(ConcurrentMapCache::new)
.map(Cache.class::cast)
.toList();
}
}

View File

@@ -0,0 +1,19 @@
package eu.bitfield.recipes.config;
import io.r2dbc.spi.Option;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.r2dbc.ConnectionFactoryOptionsBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
public class DatabaseConfiguration {
@Bean
public ConnectionFactoryOptionsBuilderCustomizer connectionFactoryOptionsBuilderCustomizer() {
return builder -> {
// builder.option(Option.valueOf("timeZone"), TimeZone.getTimeZone(ZoneOffset.UTC));
builder.option(Option.valueOf("autodetectExtensions"), false);
};
}
}

View File

@@ -0,0 +1,65 @@
package eu.bitfield.recipes.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher;
import static org.springframework.http.HttpMethod.*;
@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {
private SecurityWebFilterChain filterChainDefaults(ServerHttpSecurity http) {
// disable session management
// https://github.com/spring-projects/spring-security/issues/6552#issuecomment-519398510
return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
.build();
}
@Order(Ordered.HIGHEST_PRECEDENCE) @Bean
public SecurityWebFilterChain accountEndpointFilterChain(ServerHttpSecurity httpSecurity) {
httpSecurity.securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/account/**"))
.authorizeExchange(exchange -> exchange.anyExchange().permitAll());
return filterChainDefaults(httpSecurity);
}
@Order(Ordered.HIGHEST_PRECEDENCE) @Bean
public SecurityWebFilterChain recipeEndpointFilterChain(ServerHttpSecurity httpSecurity) {
httpSecurity.securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/recipe/**"))
.httpBasic(Customizer.withDefaults())
.authorizeExchange(exchange -> {
exchange.pathMatchers(GET, "/api/recipe/{recipeId:\\d+}").permitAll()
.pathMatchers(GET, "/api/recipe/search").permitAll()
.anyExchange().authenticated();
});
return filterChainDefaults(httpSecurity);
}
@Bean
public SecurityWebFilterChain fallbackFilterChain(ServerHttpSecurity httpSecurity) {
httpSecurity.authorizeExchange(exchange -> exchange.anyExchange().denyAll());
return filterChainDefaults(httpSecurity);
}
@Bean
public PasswordEncoder passwordEncoder() {
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
// accessed at 2025-04-22
int saltLength = 16;
int hashLength = 32;
int parallelism = 1;
int memory = 1 << 16; // in KiB = 64 MiB
int iterations = 2;
return new Argon2PasswordEncoder(saltLength, hashLength, parallelism, memory, iterations);
}
}

View File

@@ -0,0 +1,21 @@
package eu.bitfield.recipes.core.account;
import eu.bitfield.recipes.auth.email.EmailAddress;
import eu.bitfield.recipes.auth.password.Password;
import eu.bitfield.recipes.util.Entity;
import lombok.With;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
@With @FieldNameConstants
public record Account(
@Id long id,
long profileId,
String email,
@Column("password") String passwordEncoded
) implements Entity {
public static Account initial(long profileId, EmailAddress email, Password password) {
return new Account(0, profileId, email.address(), password.encoded());
}
}

View File

@@ -0,0 +1,19 @@
package eu.bitfield.recipes.core.account;
import eu.bitfield.recipes.auth.email.EmailAddressIn;
import eu.bitfield.recipes.auth.password.PasswordIn;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.With;
import org.springframework.validation.annotation.Validated;
@With
@Validated
public record AccountIn(
@NotNull @Valid EmailAddressIn email,
@NotNull @Valid PasswordIn password
) implements ToAccountIn {
public AccountIn toAccountIn() {
return this;
}
}

View File

@@ -0,0 +1,35 @@
package eu.bitfield.recipes.core.account;
import eu.bitfield.recipes.auth.email.EmailAddress;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Mono;
interface AccountRepository extends ReactiveCrudRepository<Account, Long> {
default Mono<Account> addAccount(Account account) {
return save(account);
}
@Query("""
select * from account
where email = $1
""")
Mono<Account> query_accountByEmail(String emailAddress);
default Mono<Account> accountByEmail(EmailAddress email) {
return query_accountByEmail(email.address());
}
@Query("""
select exists(
select * from account
where email = $1
)
""")
Mono<Boolean> query_isEmailUsed(String emailAddress);
default Mono<Boolean> isEmailUsed(EmailAddress email) {
return query_isEmailUsed(email.address());
}
}

View File

@@ -0,0 +1,52 @@
package eu.bitfield.recipes.core.account;
import eu.bitfield.recipes.auth.email.EmailAddress;
import eu.bitfield.recipes.auth.password.Password;
import eu.bitfield.recipes.auth.password.PasswordIn;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.core.NestedRuntimeException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import static eu.bitfield.recipes.util.AsyncUtils.*;
@RequiredArgsConstructor
@Service @CacheConfig(cacheNames = "account")
public class AccountService {
private final AccountRepository repo;
private final PasswordEncoder encoder;
@Transactional @CachePut(key = "email")
public Mono<Account> addAccount(long profileId, EmailAddress email, Password password) {
return supply(() -> Account.initial(profileId, email, password)).flatMap(repo::addAccount);
}
@Transactional(readOnly = true) @Cacheable
public Mono<Account> getAccount(EmailAddress email) {
return repo.accountByEmail(email);
}
@Transactional(readOnly = true)
public Mono<EmailAddress> checkEmail(EmailAddress email) {
return repo.isEmailUsed(email).as(errIfTrue(() -> new EmailAlreadyInUse(email))).thenReturn(email);
}
public Password encode(PasswordIn passwordIn) {
return Password.of((PasswordIn __) -> encoder.encode(passwordIn.raw()), passwordIn);
}
public static class EmailAlreadyInUse extends Error {
public EmailAlreadyInUse(EmailAddress email) {
super("address address '" + email.address() + "' is already in use");
}
}
public static class Error extends NestedRuntimeException {
public Error(String error) {super(error);}
}
}

View File

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

View File

@@ -0,0 +1,22 @@
package eu.bitfield.recipes.core.category;
import eu.bitfield.recipes.util.Entity;
import lombok.With;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
@With
@FieldNameConstants
public record Category(
@Id long id,
String name
) implements Entity, ToCategoryOut {
public static Category initial(String name) {
return new Category(0, name);
}
public CategoryOut toCategoryOut() {
return new CategoryOut(name);
}
}

View File

@@ -0,0 +1,6 @@
package eu.bitfield.recipes.core.category;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.validation.constraints.NotBlank;
public record CategoryIn(@JsonValue @NotBlank String name) {}

View File

@@ -0,0 +1,9 @@
package eu.bitfield.recipes.core.category;
import com.fasterxml.jackson.annotation.JsonValue;
public record CategoryOut(@JsonValue String name) implements ToCategoryOut {
public CategoryOut toCategoryOut() {
return this;
}
}

View File

@@ -0,0 +1,25 @@
package eu.bitfield.recipes.core.category;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface CategoryRepository extends ReactiveCrudRepository<Category, Long> {
default Mono<Category> addCategory(Category category) {
return save(category);
}
@Query("""
select * from category as c
inner join link_rec_cat as rc on c.id = rc.category_id
where rc.recipe_id = $1
""")
Flux<Category> categoriesByRecipeId(long recipeId);
@Query("""
select id from category
where name = $1
""")
Mono<Long> categoryIdByName(String name);
}

View File

@@ -0,0 +1,31 @@
package eu.bitfield.recipes.core.category;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import java.util.List;
import static eu.bitfield.recipes.util.AsyncUtils.*;
@RequiredArgsConstructor
@Service @Transactional(readOnly = true)
public class CategoryService {
private final CategoryRepository repo;
@Transactional
public Mono<List<Category>> addCategories(List<CategoryIn> categoriesIn) {
return flux(categoriesIn).flatMapSequential(this::addCategoryIfAbsent).collectList();
}
public Mono<List<Category>> getCategories(long recipeId) {
return repo.categoriesByRecipeId(recipeId).collectList();
}
private Mono<Category> addCategoryIfAbsent(CategoryIn categoryIn) {
return repo.categoryIdByName(categoryIn.name())
.map(id -> new Category(id, categoryIn.name()))
.switchIfEmpty(supply(() -> Category.initial(categoryIn.name())).flatMap(repo::addCategory));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
package eu.bitfield.recipes.core.ingredient;
import eu.bitfield.recipes.util.Entity;
import lombok.With;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
@With
@FieldNameConstants
public record Ingredient(
@Id long id,
long recipeId,
String name
) implements Entity, ToIngredientOut {
public static Ingredient initial(long recipeId, String name) {return new Ingredient(0, recipeId, name);}
public IngredientOut toIngredientOut() {
return new IngredientOut(name);
}
}

View File

@@ -0,0 +1,7 @@
package eu.bitfield.recipes.core.ingredient;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.validation.constraints.NotBlank;
public record IngredientIn(@JsonValue @NotBlank String name) {
}

View File

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

View File

@@ -0,0 +1,93 @@
package eu.bitfield.recipes.core.ingredient;
import io.r2dbc.spi.Result;
import io.r2dbc.spi.Row;
import io.r2dbc.spi.RowMetadata;
import io.r2dbc.spi.Statement;
import lombok.RequiredArgsConstructor;
import org.reactivestreams.Publisher;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.r2dbc.connection.R2dbcTransactionManager;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Spliterator;
interface IngredientBatchOperations {
Flux<Ingredient> addIngredientsBatch(Flux<Ingredient> ingredients);
}
public interface IngredientRepository extends ReactiveCrudRepository<Ingredient, Long>, IngredientBatchOperations {
// no batch support as of 2025-05-05
// https://github.com/spring-projects/spring-data-r2dbc/issues/259
// https://github.com/spring-projects/spring-framework/issues/33812
default Flux<Ingredient> addIngredients(List<Ingredient> ingredients) {
return saveAll(ingredients);
}
@Query("""
select * from ingredient
where recipe_id = $1
""")
Flux<Ingredient> ingredientsByRecipeId(long recipeId);
@Modifying
@Query("""
delete from ingredient where recipe_id = $1
""")
Mono<Long> removeIngredientsFromRecipe(long recipeId);
}
@Repository
@RequiredArgsConstructor
class IngredientBatchOperationsImpl implements IngredientBatchOperations {
private final DatabaseClient client;
private final R2dbcTransactionManager transactionManager;
private final String addBatchSql =
"""
insert into ingredient(recipe_id, name) values ($1, $2)
""";
private final String idColumn = Ingredient.Fields.id;
@Override
public Flux<Ingredient> addIngredientsBatch(Flux<Ingredient> ingredients) {
return ingredients.collectList()
.flatMapMany(this::addIngredientsBatch);
}
public Flux<Ingredient> addIngredientsBatch(List<Ingredient> ingredients) {
if (ingredients.isEmpty()) {
return Flux.empty();
}
return client.inConnectionMany(connection -> {
Statement statement = connection.createStatement(addBatchSql);
statement.returnGeneratedValues(idColumn);
Spliterator<Ingredient> iter = ingredients.spliterator();
iter.tryAdvance(ingredient -> bind(statement, ingredient));
iter.forEachRemaining(ingredient -> bind(statement.add(), ingredient));
var results = Flux.from(statement.execute());
return Flux.fromIterable(ingredients)
.zipWith(results.concatMap(this::idFromResult), Ingredient::withId);
});
}
Publisher<Long> idFromResult(Result result) {
return result.map((Row row, RowMetadata rowMetadata) -> idFromRow(row));
}
Long idFromRow(Row row) {
Long id = row.get(idColumn, Long.class);
return id;
}
void bind(Statement statement, Ingredient ingredient) {
statement.bind(0, ingredient.recipeId())
.bind(1, ingredient.name());
}
}

View File

@@ -0,0 +1,35 @@
package eu.bitfield.recipes.core.ingredient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import java.util.List;
import static eu.bitfield.recipes.util.AsyncUtils.*;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class IngredientService {
private final IngredientRepository repo;
@Transactional
public Mono<List<Ingredient>> addIngredients(long recipeId, List<IngredientIn> ingredientsIn) {
return flux(ingredientsIn)
.map(ingredientIn -> Ingredient.initial(recipeId, ingredientIn.name()))
.collectList()
.flatMapMany(repo::addIngredients)
.collectList();
}
public Mono<List<Ingredient>> getIngredients(long recipeId) {
return repo.ingredientsByRecipeId(recipeId).collectList();
}
@Transactional
public Mono<List<Ingredient>> updateIngredients(long recipeId, List<IngredientIn> ingredients) {
return repo.removeIngredientsFromRecipe(recipeId).then(addIngredients(recipeId, ingredients));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
package eu.bitfield.recipes.core.link;
import eu.bitfield.recipes.util.Entity;
import lombok.With;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
/// recipe category link
@With
@FieldNameConstants
public record LinkRecCat(
@Id long id,
long recipeId,
long categoryId
) implements Entity {
public static LinkRecCat initial(long recipeId, long categoryId) {
return new LinkRecCat(0, recipeId, categoryId);
}
}

View File

@@ -0,0 +1,21 @@
package eu.bitfield.recipes.core.link;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
public interface LinkRecCatRepository extends ReactiveCrudRepository<LinkRecCat, Long> {
default Flux<LinkRecCat> addLinks(List<LinkRecCat> links) {
return saveAll(links);
}
@Modifying
@Query("""
delete from link_rec_cat where recipe_id = $1
""")
Mono<Long> removeCategoriesFromRecipe(long recipeId);
}

View File

@@ -0,0 +1,32 @@
package eu.bitfield.recipes.core.link;
import eu.bitfield.recipes.core.category.Category;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import java.util.List;
import static eu.bitfield.recipes.util.AsyncUtils.*;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LinkRecCatService {
private final LinkRecCatRepository repo;
@Transactional
public Mono<List<LinkRecCat>> addLinks(long recipeId, List<Category> categories) {
return flux(categories)
.map(category -> LinkRecCat.initial(recipeId, category.id()))
.collectList()
.flatMapMany(repo::addLinks)
.collectList();
}
@Transactional
public Mono<List<LinkRecCat>> updateLinks(long recipeId, List<Category> categories) {
return repo.removeCategoriesFromRecipe(recipeId).then(addLinks(recipeId, categories));
}
}

View File

@@ -0,0 +1,14 @@
package eu.bitfield.recipes.core.profile;
import eu.bitfield.recipes.util.Entity;
import lombok.With;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
@With
@FieldNameConstants
public record Profile(@Id long id) implements Entity {
public static Profile initial() {return new Profile(0);}
}

View File

@@ -0,0 +1,10 @@
package eu.bitfield.recipes.core.profile;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Mono;
public interface ProfileRepository extends ReactiveCrudRepository<Profile, Long> {
default Mono<Profile> addProfile(Profile initialProfile) {
return save(initialProfile);
}
}

View File

@@ -0,0 +1,18 @@
package eu.bitfield.recipes.core.profile;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import static eu.bitfield.recipes.util.AsyncUtils.*;
@RequiredArgsConstructor
@Service @Transactional(readOnly = true)
public class ProfileService {
private final ProfileRepository repo;
@Transactional
public Mono<Profile> addProfile() {return supply(Profile::initial).flatMap(repo::addProfile);}
}

View File

@@ -0,0 +1,27 @@
package eu.bitfield.recipes.core.recipe;
import eu.bitfield.recipes.util.Entity;
import lombok.With;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
import java.time.Instant;
@With
@FieldNameConstants
public record Recipe(
@Id long id,
long authorProfileId,
String name,
String description,
Instant changedAt
) implements Entity, ToRecipeOut {
public static Recipe initial(long authorProfileId, String name, String description, Instant changedAt) {
return new Recipe(0, authorProfileId, name, description, changedAt);
}
public RecipeOut toRecipeOut() {
return new RecipeOut(id, name, description, changedAt);
}
}

View File

@@ -0,0 +1,11 @@
package eu.bitfield.recipes.core.recipe;
import jakarta.validation.constraints.NotBlank;
import lombok.With;
@With
public record RecipeIn(
@NotBlank String name,
@NotBlank String description
) {
}

View File

@@ -0,0 +1,13 @@
package eu.bitfield.recipes.core.recipe;
import lombok.With;
import java.time.Instant;
@With
public record RecipeOut(
long id,
String name,
String description,
Instant changedAt
) {}

View File

@@ -0,0 +1,61 @@
package eu.bitfield.recipes.core.recipe;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Instant;
public interface RecipeRepository extends ReactiveCrudRepository<Recipe, Long> {
default Mono<Recipe> addRecipe(Recipe recipe) {
return save(recipe);
}
default Mono<Recipe> recipe(long recipeId) {
return findById(recipeId);
}
@Modifying
@Query("""
update recipe
set name = $2,
description = $3,
changed_at = $4
where id = $1
""")
Mono<Boolean> updateRecipe(long recipeId, String name, String description, Instant changedAt);
@Modifying
@Query("""
delete from recipe
where id = $1
""")
Mono<Boolean> removeRecipe(long recipeId);
@Query("""
select id from recipe as r
where
($2 is null or position(lower($2) in lower(r.name)) > 0) and
($1 is null or
exists(
select * from category as c
inner join link_rec_cat as rc on c.id = rc.category_id
where lower(c.name) = lower($1) and rc.recipe_id = r.id
)
)
order by r.changed_at desc
limit $3
offset $4
""")
Flux<Long> recipeIds(@Nullable String categoryName, @Nullable String recipeName, long limit, long offset);
@Query("""
select author_profile_id = $2 from recipe
where id = $1
""")
Mono<Boolean> canEditRecipe(long recipeId, long profileId);
}

View File

@@ -0,0 +1,73 @@
package eu.bitfield.recipes.core.recipe;
import eu.bitfield.recipes.util.Pagination;
import lombok.RequiredArgsConstructor;
import org.springframework.core.NestedRuntimeException;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
import static eu.bitfield.recipes.util.AsyncUtils.*;
@RequiredArgsConstructor
@Service @Transactional(readOnly = true)
public class RecipeService {
private final RecipeRepository repo;
@Transactional
public Mono<Recipe> addRecipe(long authorProfileId, RecipeIn recipeIn, Instant createdAt) {
return repo.addRecipe(Recipe.initial(authorProfileId, recipeIn.name(), recipeIn.description(), createdAt));
}
public Mono<Recipe> getRecipe(long recipeId) {
return repo.recipe(recipeId).as(checkEmpty(recipeId));
}
public Flux<Long> findRecipeIds(@Nullable String categoryName, @Nullable String recipeName, Pagination pagination) {
return repo.recipeIds(categoryName, recipeName, pagination.limit(), pagination.offset());
}
public Mono<Recipe> updateRecipe(long recipeId, long profileId, RecipeIn recipeIn, Instant changedAt) {
return checkEdit(recipeId, profileId, () -> new UpdateRecipeForbidden(recipeId))
.then(repo.updateRecipe(recipeId, recipeIn.name(), recipeIn.description(), changedAt))
.then(supply(() -> new Recipe(recipeId, profileId, recipeIn.name(), recipeIn.description(), changedAt)));
}
@Transactional
public Mono<Boolean> removeRecipe(long recipeId, long profileId) {
return checkEdit(recipeId, profileId, () -> new RemoveRecipeForbidden(recipeId))
.then(repo.removeRecipe(recipeId));
}
private <T> UnaryOperator<Mono<T>> checkEmpty(long recipeId) {
return (Mono<T> item) -> item.as(errIfEmpty(() -> new RecipeNotFound(recipeId)));
}
private Mono<Boolean> checkEdit(long recipeId, long profileId, Supplier<? extends Throwable> forbiddenError) {
return repo.canEditRecipe(recipeId, profileId)
.as(checkEmpty(recipeId))
.as(errIfFalse(forbiddenError));
}
public static class RecipeNotFound extends Error {
public RecipeNotFound(long recipeId) {super("no such recipe (id=" + recipeId + ")");}
}
public static class UpdateRecipeForbidden extends Error {
public UpdateRecipeForbidden(long recipeId) {super("updating recipe (id=" + recipeId + ") is not allowed");}
}
public static class RemoveRecipeForbidden extends Error {
public RemoveRecipeForbidden(long recipeId) {super("removing recipe (id=" + recipeId + ") is not allowed");}
}
public static class Error extends NestedRuntimeException {
public Error(String error) {super(error);}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
package eu.bitfield.recipes.core.step;
import eu.bitfield.recipes.util.Entity;
import lombok.With;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
@With
@FieldNameConstants
public record Step(
@Id long id,
long recipeId,
String name
) implements Entity, ToStepOut {
public static Step initial(long recipeId, String name) {return new Step(0, recipeId, name);}
public StepOut toStepOut() {
return new StepOut(name);
}
}

View File

@@ -0,0 +1,7 @@
package eu.bitfield.recipes.core.step;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.validation.constraints.NotBlank;
public record StepIn(@JsonValue @NotBlank String name) {
}

View File

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

View File

@@ -0,0 +1,31 @@
package eu.bitfield.recipes.core.step;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
public interface StepRepository extends ReactiveCrudRepository<Step, Long> {
// no batch support as of 2025-05-05
// https://github.com/spring-projects/spring-data-r2dbc/issues/259
// https://github.com/spring-projects/spring-framework/issues/33812
default Flux<Step> addSteps(List<Step> steps) {
return saveAll(steps);
}
@Query("""
select * from step
where recipe_id = $1
order by id;
""")
Flux<Step> stepsByRecipeId(long recipeId);
@Modifying
@Query("""
delete from step where recipe_id = $1
""")
Mono<Long> removeStepsFromRecipe(long recipeId);
}

View File

@@ -0,0 +1,32 @@
package eu.bitfield.recipes.core.step;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Mono;
import java.util.List;
import static eu.bitfield.recipes.util.AsyncUtils.*;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StepService {
private final StepRepository repo;
public Mono<List<Step>> addSteps(long recipeId, List<StepIn> stepsIn) {
return flux(stepsIn).map(stepIn -> Step.initial(recipeId, stepIn.name()))
.collectList()
.flatMapMany(repo::addSteps)
.collectList();
}
public Mono<List<Step>> getSteps(long recipeId) {
return repo.stepsByRecipeId(recipeId).collectList();
}
public Mono<List<Step>> updateSteps(long recipeId, List<StepIn> stepsIn) {
return repo.removeStepsFromRecipe(recipeId).then(addSteps(recipeId, stepsIn));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
package eu.bitfield.recipes.log;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.boolex.EvaluationException;
import ch.qos.logback.core.boolex.EventEvaluatorBase;
import ch.qos.logback.core.boolex.Matcher;
import lombok.Getter;
import lombok.Setter;
public class EventMatcherEvaluator extends EventEvaluatorBase<ILoggingEvent> {
@Getter @Setter String messageRegex = ".*";
@Getter @Setter String loggerNameRegex = ".*";
Matcher messageMatcher = new Matcher();
Matcher loggerNameMatcher = new Matcher();
public boolean evaluate(ILoggingEvent event) throws EvaluationException {
return messageMatcher.matches(event.getFormattedMessage()) && loggerNameMatcher.matches(event.getLoggerName());
}
public void start() {
messageMatcher.setName("messageMatcher");
messageMatcher.setRegex(messageRegex);
messageMatcher.start();
loggerNameMatcher.setName("messageMatcher");
loggerNameMatcher.setRegex(loggerNameRegex);
loggerNameMatcher.start();
super.start();
}
public void stop() {
super.stop();
loggerNameMatcher.stop();
messageMatcher.stop();
}
}

View File

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

View File

@@ -0,0 +1,74 @@
package eu.bitfield.recipes.util;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import static java.util.function.Function.*;
public class AsyncUtils {
public static <T, R> Function<T, R> supplyFn(Supplier<R> supplier) {
return (T t) -> supplier.get();
}
public static <T> UnaryOperator<Mono<T>> errIfEmpty(Supplier<? extends Throwable> err) {
return (Mono<T> mono) -> mono.switchIfEmpty(err(err));
}
public static <T> UnaryOperator<Mono<T>> errIf(Predicate<T> shouldErr, Function<T, ? extends Throwable> err) {
return (Mono<T> mono) -> mono.flatMap(item -> shouldErr.test(item) ? err(err.apply(item)) : some(item));
}
public static <T> UnaryOperator<Mono<T>> errIfNot(Predicate<T> isOk, Function<T, ? extends Throwable> err) {
return (Mono<T> mono) -> mono.flatMap(item -> isOk.test(item) ? some(item) : err(err.apply(item)));
}
public static <T> Mono<T> some(T some) {return Mono.just(some);}
@SafeVarargs
public static <T> Flux<T> many(T... many) {return Flux.just(many);}
public static <T> Mono<T> none() {return Mono.empty();}
public static <T> Mono<T> err(Supplier<? extends Throwable> error) {return Mono.error(error);}
public static <T> Mono<T> err(Throwable error) {return Mono.error(error);}
public static Function<Mono<Boolean>, Mono<Boolean>> errIfTrue(Supplier<? extends Throwable> err) {
return (Mono<Boolean> mono) -> mono.flatMap(ok -> ok ? err(err.get()) : some(false));
}
public static Function<Mono<Boolean>, Mono<Boolean>> errIfFalse(Supplier<? extends Throwable> errorSupplier) {
return (Mono<Boolean> mono) -> mono.flatMap(ok -> ok ? some(true) : err(errorSupplier.get()));
}
public static <T> Mono<T> supply(Supplier<? extends T> supplier) {return Mono.fromSupplier(supplier);}
public static <T> Flux<T> flux(Iterable<? extends T> iterable) {return Flux.fromIterable(iterable);}
public static <T> Flux<T> flux(Stream<? extends T> stream) {return Flux.fromStream(stream);}
public static <T1, T2> UnaryOperator<Mono<T1>> chain(Function<T1, Mono<? extends T2>> visit) {
return (Mono<T1> mono) -> mono.flatMap(t1 -> visit.apply(t1).thenReturn(t1));
}
public static <T> Mono<T> defer(MonoSupplier<? extends T> supplier) {return Mono.defer(supplier);}
public static <T> Flux<T> defer(FluxSupplier<T> supplier) {return Flux.defer(supplier);}
public static <T, V> UnaryOperator<Flux<T>> takeUntilChanged(Function<? super T, ? super V> keySelector) {
return (Flux<T> items) -> items.windowUntilChanged(keySelector).take(1).flatMap(identity());
}
@FunctionalInterface
public interface MonoSupplier<T> extends Supplier<Mono<T>> {}
@FunctionalInterface
public interface FluxSupplier<T> extends Supplier<Flux<T>> {}
}

View File

@@ -0,0 +1,11 @@
package eu.bitfield.recipes.util;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@Component
public class Chronology {
public Instant now() {return Instant.now().truncatedTo(ChronoUnit.MICROS);}
}

View File

@@ -0,0 +1,33 @@
package eu.bitfield.recipes.util;
import org.springframework.util.MultiValueMap;
import java.util.*;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collector;
import static java.util.stream.Collectors.*;
public class CollectionUtils {
public static <K, V> MultiValueMap<K, V> multiValueMap() {
return MultiValueMap.fromMultiValue(new HashMap<>());
}
public static <K, V> MultiValueMap<K, V> multiValueMap(Map<K, List<V>> map) {
return MultiValueMap.fromMultiValue(map);
}
public static <T> Collector<T, ?, HashSet<T>> toHashSet() {
return toCollection(HashSet::new);
}
public static <T> Collector<T, ?, ArrayList<T>> toArrayList() {
return toCollection(ArrayList::new);
}
public static <K, V, R> Function<Map.Entry<K, V>, R> entryFn(BiFunction<K, V, R> fn) {
return entry -> fn.apply(entry.getKey(), entry.getValue());
}
}

View File

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

View File

@@ -0,0 +1,25 @@
package eu.bitfield.recipes.util;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponse;
import org.springframework.web.ErrorResponseException;
public class ErrorUtils {
public static ErrorResponseException errorResponseException(Throwable ex, HttpStatusCode statusCode) {
ErrorResponse response = errorResponse(ex, statusCode);
return new ErrorResponseException(response.getStatusCode(), response.getBody(), ex);
}
public static ErrorResponse errorResponse(Throwable ex, HttpStatusCode statusCode) {
StringBuilder messageBuilder = new StringBuilder(ex.getMessage());
Throwable cause = ex.getCause();
while (cause != null) {
messageBuilder.append(" > ").append(cause.getMessage());
cause = cause.getCause();
}
String message = messageBuilder.toString();
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(statusCode, message);
return ErrorResponse.builder(ex, problemDetail).build();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
package eu.bitfield.recipes.view.recipe;
import eu.bitfield.recipes.core.category.Category;
import eu.bitfield.recipes.core.category.CategoryOut;
import eu.bitfield.recipes.core.ingredient.Ingredient;
import eu.bitfield.recipes.core.ingredient.IngredientOut;
import eu.bitfield.recipes.core.link.LinkRecCat;
import eu.bitfield.recipes.core.recipe.Recipe;
import eu.bitfield.recipes.core.recipe.RecipeOut;
import eu.bitfield.recipes.core.step.Step;
import eu.bitfield.recipes.core.step.StepOut;
import java.util.List;
public record RecipeView(
Recipe recipe,
List<Category> categories,
List<LinkRecCat> links,
List<Ingredient> ingredients,
List<Step> steps
) implements ToRecipeViewOut {
public static RecipeViewOut createRecipeViewOut(
Recipe recipe,
List<Category> categories,
List<Ingredient> ingredients,
List<Step> steps)
{
RecipeOut recipeOut = recipe.toRecipeOut();
List<CategoryOut> categoriesOut = categories.stream().map(Category::toCategoryOut).toList();
List<IngredientOut> ingredientsOut = ingredients.stream().map(Ingredient::toIngredientOut).toList();
List<StepOut> stepsOut = steps.stream().map(Step::toStepOut).toList();
return new RecipeViewOut(recipeOut, categoriesOut, ingredientsOut, stepsOut);
}
public RecipeViewOut toRecipeViewOut() {
return createRecipeViewOut(recipe, categories, ingredients, steps);
}
}

View File

@@ -0,0 +1,25 @@
package eu.bitfield.recipes.view.recipe;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import eu.bitfield.recipes.core.category.CategoryIn;
import eu.bitfield.recipes.core.ingredient.IngredientIn;
import eu.bitfield.recipes.core.recipe.RecipeIn;
import eu.bitfield.recipes.core.step.StepIn;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.With;
import java.util.List;
@With
public record RecipeViewIn(
@JsonUnwrapped @NotNull @Valid RecipeIn recipe,
@NotNull List<@Valid @NotNull CategoryIn> categories,
@NotEmpty List<@Valid @NotNull IngredientIn> ingredients,
@NotEmpty List<@Valid @NotNull StepIn> steps
) implements ToRecipeViewIn {
public RecipeViewIn toRecipeViewIn() {
return this;
}
}

View File

@@ -0,0 +1,23 @@
package eu.bitfield.recipes.view.recipe;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import eu.bitfield.recipes.core.category.CategoryOut;
import eu.bitfield.recipes.core.ingredient.IngredientOut;
import eu.bitfield.recipes.core.recipe.RecipeOut;
import eu.bitfield.recipes.core.recipe.ToRecipeOut;
import eu.bitfield.recipes.core.step.StepOut;
import lombok.With;
import java.util.List;
@With
public record RecipeViewOut(
@JsonUnwrapped RecipeOut recipe,
List<CategoryOut> categories,
List<IngredientOut> ingredients,
List<StepOut> steps
) implements ToRecipeOut {
public RecipeOut toRecipeOut() {
return recipe;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package eu.bitfield.recipes.view.registration;
import eu.bitfield.recipes.core.account.Account;
import eu.bitfield.recipes.core.profile.Profile;
public record RegistrationView(
Profile profile,
Account account
) {}

View File

@@ -0,0 +1,31 @@
spring:
application:
name: recipes
r2dbc:
url: "r2dbc:postgresql://localhost:5432/recipes"
username: "recipes"
password: "dev_pw"
main:
banner-mode: "off"
web-application-type: reactive
webflux:
problemdetails:
enabled: true
# jackson:
# serialization:
# INDENT_OUTPUT: true
#logging:
# level:
# io.r2dbc.pool: debug
# io.r2dbc.postgresql.QUERY: debug
# io.r2dbc.postgresql.PARAM: debug
# org.springframework.r2dbc: debug
# org.springframework.transaction: trace
# org.springframework.r2dbc.connection.init.ScriptUtils: info
# eu.bitfield.recipes.test.api.APICall: debug
# eu.bitfield.recipes.api.ErrorResponseHandling: info
# org.springframework.security: debug
# reactor.netty.http.client: debug
# reactor.netty.http.server: debug
# org.springframework: debug

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_LOG_THRESHOLD}</level>
</filter>
<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
<evaluator class="eu.bitfield.recipes.log.EventMatcherEvaluator">
<loggerNameRegex>^io\.r2dbc\.postgresql\.QUERY$</loggerNameRegex>
<messageRegex>Executing query: SHOW TRANSACTION ISOLATION LEVEL</messageRegex>
</evaluator>
<OnMatch>DENY</OnMatch>
<OnMismatch>NEUTRAL</OnMismatch>
</filter>
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="org.springframework" level="WARN"/>
</configuration>

View File

@@ -0,0 +1,49 @@
drop table if exists profile cascade;
create table profile (
id bigint primary key generated always as identity
);
drop table if exists account cascade;
create table account (
id bigint primary key generated always as identity,
profile_id bigint references profile on delete cascade,
email text not null unique,
password text not null
);
drop table if exists recipe cascade;
create table recipe (
id bigint primary key generated always as identity,
author_profile_id bigint not null references profile on delete cascade,
name text not null,
description text not null,
changed_at timestamp not null
);
drop table if exists category cascade;
create table category (
id bigint primary key generated always as identity,
name text not null unique
);
drop table if exists link_rec_cat cascade;
create table link_rec_cat (
id bigint primary key generated always as identity,
recipe_id bigint not null references recipe on delete cascade,
category_id bigint not null references category on delete cascade,
unique (recipe_id, category_id)
);
drop table if exists ingredient cascade;
create table ingredient (
id bigint primary key generated always as identity,
recipe_id bigint references recipe on delete cascade,
name text not null
);
drop table if exists step cascade;
create table step (
id bigint primary key generated always as identity,
recipe_id bigint references recipe on delete cascade,
name text not null
);

View File

@@ -0,0 +1,178 @@
package eu.bitfield.recipes.api;
import eu.bitfield.recipes.api.recipe.RecipeEndpoint;
import eu.bitfield.recipes.core.recipe.RecipeOut;
import eu.bitfield.recipes.test.api.APICalls;
import eu.bitfield.recipes.test.core.account.AccountLayer;
import eu.bitfield.recipes.test.core.account.AccountQueries;
import eu.bitfield.recipes.test.core.account.AccountSlot;
import eu.bitfield.recipes.test.core.category.CategoryLayer;
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
import eu.bitfield.recipes.test.core.recipe.RecipeTemplate;
import eu.bitfield.recipes.test.core.step.StepLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
import eu.bitfield.recipes.util.Pagination;
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
import io.r2dbc.spi.ConnectionFactory;
import lombok.Getter;
import lombok.experimental.Accessors;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.Resource;
import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator;
import org.springframework.test.web.reactive.server.WebTestClient;
import java.time.Instant;
import java.util.List;
import static eu.bitfield.recipes.test.InvalidEntity.*;
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static org.assertj.core.api.Assertions.*;
@Getter @Accessors(fluent = true)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class APITest implements AccountQueries, RecipeViewQueries, APICalls {
static final Pagination pagination = new Pagination(RecipeEndpoint.MAX_LIMIT, 0L);
@Autowired WebTestClient client;
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers().profileLayer();
@Delegate AccountLayer accountLayer = layers().accountLayer();
@Delegate RecipeLayer recipeLayer = layers().recipeLayer();
@Delegate CategoryLayer categoryLayer = layers().categoryLayer();
@Delegate LinkRecCatLayer linkLayer = layers().linkRecCatLayer();
@Delegate IngredientLayer ingredientLayer = layers().ingredientLayer();
@Delegate StepLayer stepLayer = layers().stepLayer();
static void cleanDatabase(ConnectionFactory connectionFactory, Resource cleanSql) {
var resourceDatabasePopulator = new ResourceDatabasePopulator(cleanSql);
resourceDatabasePopulator.addScript(cleanSql);
resourceDatabasePopulator.populate(connectionFactory).block();
}
@AfterAll
static void afterAll(@Autowired ConnectionFactory connectionFactory,
@Value("classpath:/clean.sql") Resource cleanSql)
{
cleanDatabase(connectionFactory, cleanSql);
}
@BeforeEach
void beforeEach(@Autowired ConnectionFactory connectionFactory,
@Value("classpath:/clean.sql") Resource cleanSql)
{
cleanDatabase(connectionFactory, cleanSql);
}
@Test
void authentificationRequired() {
long recipeId = freeId;
RecipeViewIn viewIn = recipeViewGroup().toRecipeViewIn();
addRecipeCall(viewIn).anonymous().unauthorized();
updateRecipeCall(recipeId, viewIn).anonymous().unauthorized();
removeRecipeCall(recipeId).anonymous().unauthorized();
}
@Test
void accountReregistration() {
AccountSlot account = accountSlot().blank();
registerAccountCall(account).ok();
registerAccountCall(account).badRequest();
}
@Test
void recipeCrudFlow() {
AccountSlot author = accountSlot(profiles.ada).blank();
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
RecipeViewGroup group = recipeViewGroup(recipe);
RecipeViewIn viewIn = group.toRecipeViewIn();
String updatedName = viewIn.recipe().name() + " v2";
RecipeTemplate updatedRecipeTemplate = group.recipe().template().withName(updatedName);
RecipeSlot updatedRecipe = recipeSlot().template(updatedRecipeTemplate).author(author.profile()).blank();
RecipeViewGroup updatedGroup = recipeViewGroup(updatedRecipe);
RecipeViewIn updatedViewIn = updatedGroup.toRecipeViewIn();
save(combinedSlots(group, updatedGroup));
registerAccountCall(author).ok();
registerAccountCall(notAuthor).ok();
RecipeViewOut addedViewOut = addRecipeCall(viewIn).as(author).ok();
assertThat(addedViewOut).isEqualTo(expectedViewOut(addedViewOut, group));
long recipeId = addedViewOut.recipe().id();
RecipeViewOut gotViewOut = getRecipeCall(recipeId).ok();
assertThat(addedViewOut).isEqualTo(gotViewOut);
removeRecipeCall(recipeId).as(notAuthor).forbidden();
getRecipeCall(recipeId).ok();
updateRecipeCall(recipeId, updatedViewIn).as(notAuthor).forbidden();
gotViewOut = getRecipeCall(recipeId).ok();
assertThat(addedViewOut).isEqualTo(gotViewOut);
RecipeViewOut updatedViewOut = updateRecipeCall(recipeId, updatedViewIn).as(author).ok();
assertThat(updatedViewOut).isEqualTo(expectedViewOut(updatedViewOut, updatedGroup));
gotViewOut = getRecipeCall(recipeId).ok();
assertThat(gotViewOut).isEqualTo(updatedViewOut);
removeRecipeCall(recipeId).as(author).ok();
getRecipeCall(recipeId).notFound();
}
@Test
void recipeSearch() {
AccountSlot account = accountSlot().blank();
registerAccountCall(account).ok();
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
save(combinedSlots(allGroups));
List<RecipeViewOut> allViewsOut = allGroups.stream()
.filter(group -> !group.ingredients().isEmpty() && !group.steps().isEmpty())
.map(group -> addRecipeCall(group.toRecipeViewIn()).as(account).ok())
.toList();
allViewsOut = allViewsOut.reversed(); // newest recipe first
String categoryName = categorySlotWithLinkCount(2).blank().name();
List<RecipeViewOut> viewsOut = allViewsOut.stream()
.filter((RecipeViewOut viewOut) -> viewOut.categories().stream().anyMatch(hasCategoryName(categoryName)))
.toList();
List<RecipeViewOut> actualViewsOut = findRecipesCall().category(categoryName).pagination(pagination).ok();
assertThat(actualViewsOut).isEqualTo(viewsOut);
String recipeName = "ma";
viewsOut = allViewsOut.stream().filter(containsRecipeName(recipeName)).toList();
actualViewsOut = findRecipesCall().recipe(recipeName).pagination(pagination).ok();
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
}
private RecipeViewOut expectedViewOut(RecipeViewOut actual, RecipeViewGroup group) {
RecipeViewOut assumed = group.toRecipeViewOut();
long actualRecipeId = actual.recipe().id();
Instant actualChangedAt = actual.recipe().changedAt();
RecipeOut expectedRecipe = assumed.recipe().withId(actualRecipeId).withChangedAt(actualChangedAt);
return assumed.withRecipe(expectedRecipe);
}
}

View File

@@ -0,0 +1,83 @@
package eu.bitfield.recipes.api.account;
import eu.bitfield.recipes.auth.email.EmailAddressIn;
import eu.bitfield.recipes.auth.password.PasswordIn;
import eu.bitfield.recipes.core.account.AccountIn;
import eu.bitfield.recipes.core.account.AccountService;
import eu.bitfield.recipes.test.api.APICalls;
import eu.bitfield.recipes.test.core.account.AccountLayer;
import eu.bitfield.recipes.test.core.account.AccountQueries;
import eu.bitfield.recipes.test.core.account.AccountSlot;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import eu.bitfield.recipes.view.registration.RegistrationView;
import lombok.Getter;
import lombok.experimental.Accessors;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static eu.bitfield.recipes.util.TestUtils.*;
import static org.mockito.Mockito.*;
@Getter @Accessors(fluent = true)
@WebFluxTest(value = AccountEndpoint.class, excludeAutoConfiguration = {
ReactiveUserDetailsServiceAutoConfiguration.class,
ReactiveSecurityAutoConfiguration.class
})
public class AccountEndpointTest implements AccountQueries, APICalls {
@MockitoBean RegisterAccount registerAccount;
@Autowired WebTestClient client;
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate AccountLayer accountLayer = layers.accountLayer();
@Test
void registerAccount_accountInEmailOk_ok() {
AccountSlot account = accountSlot().blank();
save(slot(account));
AccountIn accountIn = account.toAccountIn();
var registrationView = new RegistrationView(account.profile().saved(), account.saved());
when(registerAccount.registerAccount(accountIn)).thenReturn(some(registrationView));
registerAccountCall(accountIn).ok();
}
@Test
void registerAccount_accountInEmailInUse_badRequest() {
AccountSlot account = accountSlot().blank();
save(slot(account));
AccountIn accountIn = account.toAccountIn();
var err = new RegisterAccount.EmailAlreadyInUse(new AccountService.EmailAlreadyInUse(account.email()));
when(registerAccount.registerAccount(accountIn)).thenReturn(err(err));
registerAccountCall(accountIn).badRequest();
}
@Test
void registerAccount_invalidAccountIn_badRequest() {
AccountSlot account = accountSlot().blank();
save(slot(account));
AccountIn accountIn = account.toAccountIn();
EmailAddressIn invalidEmail = EmailAddressIn.ofUnchecked(accountIn.email().address() + "@@");
PasswordIn invalidPassword = PasswordIn.ofUnchecked("shortpw");
accountIn = accountIn.withEmail(invalidEmail).withPassword(invalidPassword);
when(registerAccount.registerAccount(any())).thenReturn(noSubscriptionMono());
registerAccountCall(accountIn).badRequest();
}
}

View File

@@ -0,0 +1,70 @@
package eu.bitfield.recipes.api.account;
import eu.bitfield.recipes.auth.email.EmailAddress;
import eu.bitfield.recipes.auth.password.Password;
import eu.bitfield.recipes.core.account.AccountIn;
import eu.bitfield.recipes.core.account.AccountService;
import eu.bitfield.recipes.core.profile.ProfileService;
import eu.bitfield.recipes.test.core.account.AccountLayer;
import eu.bitfield.recipes.test.core.account.AccountQueries;
import eu.bitfield.recipes.test.core.account.AccountSlot;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import eu.bitfield.recipes.view.registration.RegistrationView;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static eu.bitfield.recipes.util.TestUtils.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
public class RegisterAccountTest implements AccountQueries {
ProfileService profileServ = mock(ProfileService.class);
AccountService accountServ = mock(AccountService.class);
RegisterAccount registerAccount = new RegisterAccount(profileServ, accountServ);
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate AccountLayer accountLayer = layers.accountLayer();
@Test
void register_accountInWithUnusedEmail_accountRegistrationRegisterAccount() {
AccountSlot account = accountSlot().blank();
save(slot(account));
AccountIn accountIn = account.toAccountIn();
EmailAddress email = account.email();
Password password = account.password();
var registrationView = new RegistrationView(account.profile().saved(), account.saved());
when(accountServ.checkEmail(email)).thenReturn(some(email));
when(profileServ.addProfile()).thenReturn(some(account.profile().saved()));
when(accountServ.encode(account.passwordIn())).thenReturn(password);
when(accountServ.addAccount(account.profile().id(), email, password)).thenReturn(some(account.saved()));
registerAccount.registerAccount(accountIn)
.as(StepVerifier::create)
.expectNext(registrationView)
.verifyComplete();
}
@Test
void registerAccount_accountInWithUsedEmail_errorEmailAlreadyInUse() {
AccountSlot account = accountSlot().blank();
save(slot(account));
AccountIn accountIn = account.toAccountIn();
EmailAddress email = account.email();
when(accountServ.checkEmail(email)).thenReturn(err(new AccountService.EmailAlreadyInUse(email)));
when(profileServ.addProfile()).thenReturn(noSubscriptionMono());
when(accountServ.addAccount(anyLong(), any(), any())).thenReturn(noSubscriptionMono());
registerAccount.registerAccount(accountIn)
.as(StepVerifier::create)
.verifyError(RegisterAccount.EmailAlreadyInUse.class);
}
}

View File

@@ -0,0 +1,71 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.category.CategoryService;
import eu.bitfield.recipes.core.ingredient.IngredientService;
import eu.bitfield.recipes.core.link.LinkRecCatService;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.core.step.StepService;
import eu.bitfield.recipes.test.core.category.CategoryLayer;
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
import eu.bitfield.recipes.test.core.step.StepLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
import eu.bitfield.recipes.util.Chronology;
import eu.bitfield.recipes.view.recipe.RecipeView;
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import java.time.Instant;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static org.mockito.Mockito.*;
public class AddRecipeTest implements RecipeViewQueries {
RecipeService recipeServ = mock(RecipeService.class);
CategoryService categoryServ = mock(CategoryService.class);
LinkRecCatService linkServ = mock(LinkRecCatService.class);
IngredientService ingredientServ = mock(IngredientService.class);
StepService stepServ = mock(StepService.class);
Chronology time = mock(Chronology.class);
AddRecipe addRecipe = new AddRecipe(recipeServ, categoryServ, linkServ, ingredientServ, stepServ, time);
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
@Delegate StepLayer stepLayer = layers.stepLayer();
@Test
void addRecipe_anyRecipeViewIn_recipeViewAddRecipe() {
RecipeViewGroup group = recipeViewGroup();
save(slots(group));
RecipeViewIn viewIn = group.toRecipeViewIn();
RecipeView view = group.toRecipeView();
RecipeSlot recipe = group.recipe();
Instant createdAt = recipe.saved().changedAt();
when(time.now()).thenReturn(createdAt);
when(recipeServ.addRecipe(recipe.author().id(), viewIn.recipe(), createdAt)).thenReturn(some(recipe.saved()));
when(categoryServ.addCategories(viewIn.categories())).thenReturn(some(view.categories()));
when(linkServ.addLinks(recipe.id(), view.categories())).thenReturn(some(view.links()));
when(ingredientServ.addIngredients(recipe.id(), viewIn.ingredients())).thenReturn(some(view.ingredients()));
when(stepServ.addSteps(recipe.id(), viewIn.steps())).thenReturn(some(view.steps()));
addRecipe.addRecipe(viewIn, recipe.author().id())
.as(StepVerifier::create)
.expectNext(view)
.verifyComplete();
}
}

View File

@@ -0,0 +1,94 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.test.core.category.CategoryLayer;
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
import eu.bitfield.recipes.test.core.step.StepLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
import eu.bitfield.recipes.util.Pagination;
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import java.util.List;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static eu.bitfield.recipes.util.To.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
public class FindRecipesTest implements RecipeViewQueries {
RecipeService recipeServ = mock(RecipeService.class);
GetRecipe getRecipe = mock(GetRecipe.class);
FindRecipes findRecipes = new FindRecipes(recipeServ, getRecipe);
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
@Delegate StepLayer stepLayer = layers.stepLayer();
@Test
void findRecipes_categoryName_recipeViewsOut() {
String categoryName = categorySlotWithLinkCount(2).blank().name();
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
save(combinedSlots(allGroups));
List<RecipeViewGroup> groups = allGroups.stream()
.filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName)))
.sorted(orderByRecipeChangedAtDesc())
.toList();
List<RecipeViewOut> savedViewsOut = groups.stream().map(toRecipeViewOut).toList();
List<Long> recipeIds = groups.stream().map(toRecipe).map(toId).toList();
long recipeCount = recipeIds.size();
var pagination = new Pagination(recipeCount, 0);
String recipeName = null;
for (var group : allGroups) {
when(getRecipe.getRecipe(group.recipe().id())).thenReturn(some(group.toRecipeViewOut()));
}
when(recipeServ.findRecipeIds(categoryName, recipeName, pagination)).thenReturn(flux(recipeIds));
findRecipes.findRecipes(categoryName, recipeName, pagination)
.as(StepVerifier::create)
.assertNext((List<RecipeViewOut> actualViewsOut) -> {
assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut);
});
}
@Test
void findRecipes_recipeNamePart_recipeViewsOut() {
String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
save(combinedSlots(allGroups));
List<RecipeViewGroup> groups = allGroups.stream()
.filter(containsRecipeName(recipeName))
.sorted(orderByRecipeChangedAtDesc())
.toList();
List<RecipeViewOut> savedViewsOut = groups.stream().map(toRecipeViewOut).toList();
List<Long> recipeIds = groups.stream().map(toRecipe).map(toId).toList();
long recipeCount = recipeIds.size();
var pagination = new Pagination(recipeCount, 0);
String categoryName = null;
for (var group : allGroups) {
when(getRecipe.getRecipe(group.recipe().id())).thenReturn(some(group.toRecipeViewOut()));
}
when(recipeServ.findRecipeIds(categoryName, recipeName, pagination)).thenReturn(flux(recipeIds));
findRecipes.findRecipes(categoryName, recipeName, pagination)
.as(StepVerifier::create)
.assertNext((List<RecipeViewOut> actualViewsOut) -> {
assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut);
});
}
}

View File

@@ -0,0 +1,73 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.category.CategoryService;
import eu.bitfield.recipes.core.ingredient.IngredientService;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.core.step.StepService;
import eu.bitfield.recipes.test.core.category.CategoryLayer;
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
import eu.bitfield.recipes.test.core.step.StepLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
import eu.bitfield.recipes.view.recipe.RecipeView;
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import static eu.bitfield.recipes.test.InvalidEntity.*;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static org.mockito.Mockito.*;
public class GetRecipeTest implements RecipeViewQueries {
RecipeService recipeServ = mock(RecipeService.class);
CategoryService categoryServ = mock(CategoryService.class);
IngredientService ingredientServ = mock(IngredientService.class);
StepService stepServ = mock(StepService.class);
GetRecipe getRecipe = new GetRecipe(recipeServ, categoryServ, ingredientServ, stepServ);
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
@Delegate StepLayer stepLayer = layers.stepLayer();
@Test
void getRecipe_nonExistentRecipeId_errorNotFound() {
long recipeId = freeId;
when(recipeServ.getRecipe(recipeId)).thenReturn(err(new RecipeService.RecipeNotFound(freeId)));
getRecipe.getRecipe(recipeId)
.as(StepVerifier::create)
.verifyError(GetRecipe.NotFound.class);
}
@Test
void getRecipe_existentRecipeId_recipeViewOutGetRecipe() {
RecipeViewGroup group = recipeViewGroup();
save(slots(group));
RecipeSlot recipe = group.recipe();
RecipeView view = group.toRecipeView();
RecipeViewOut viewOut = group.toRecipeViewOut();
when(recipeServ.getRecipe(recipe.id())).thenReturn(some(view.recipe()));
when(categoryServ.getCategories(recipe.id())).thenReturn(some(view.categories()));
when(ingredientServ.getIngredients(recipe.id())).thenReturn(some(view.ingredients()));
when(stepServ.getSteps(recipe.id())).thenReturn(some(view.steps()));
getRecipe.getRecipe(recipe.id())
.as(StepVerifier::create)
.expectNext(viewOut)
.verifyComplete();
}
}

View File

@@ -0,0 +1,276 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.auth.ProfileIdentityAccess;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.test.InvalidEntity;
import eu.bitfield.recipes.test.api.APICalls;
import eu.bitfield.recipes.test.core.account.AccountLayer;
import eu.bitfield.recipes.test.core.account.AccountQueries;
import eu.bitfield.recipes.test.core.account.AccountSlot;
import eu.bitfield.recipes.test.core.category.CategoryLayer;
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
import eu.bitfield.recipes.test.core.step.StepLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
import eu.bitfield.recipes.util.Pagination;
import eu.bitfield.recipes.view.recipe.RecipeView;
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
import eu.bitfield.recipes.view.recipe.RecipeViewOut;
import lombok.Getter;
import lombok.experimental.Accessors;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import java.util.List;
import static eu.bitfield.recipes.api.recipe.RecipeEndpoint.*;
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static eu.bitfield.recipes.util.TestUtils.*;
import static eu.bitfield.recipes.util.To.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@WebFluxTest(value = RecipeEndpoint.class, excludeAutoConfiguration = {
ReactiveUserDetailsServiceAutoConfiguration.class,
ReactiveSecurityAutoConfiguration.class
})
@Getter @Accessors(fluent = true)
public class RecipeEndpointTest implements RecipeViewQueries, AccountQueries, APICalls {
static final Pagination pagination = new Pagination(RecipeEndpoint.MAX_LIMIT, 0L);
static final long freeRecipeId = InvalidEntity.freeId;
@MockitoBean AddRecipe addRecipe;
@MockitoBean GetRecipe getRecipe;
@MockitoBean UpdateRecipe updateRecipe;
@MockitoBean RemoveRecipe removeRecipe;
@MockitoBean FindRecipes findRecipes;
@MockitoBean ProfileIdentityAccess profileIdentity;
@Autowired WebTestClient client;
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate AccountLayer accountLayer = layers.accountLayer();
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
@Delegate StepLayer stepLayer = layers.stepLayer();
@Test
void addRecipe_validRecipeIn_okRecipeId() {
RecipeViewGroup group = recipeViewGroup();
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
save(slots(group).add(author));
RecipeViewIn viewIn = group.toRecipeViewIn();
RecipeView view = group.toRecipeView();
RecipeViewOut viewOut = group.toRecipeViewOut();
when(profileIdentity.id()).thenReturn(some(author.profile().id()));
when(addRecipe.addRecipe(viewIn, author.profile().id())).thenReturn(some(view));
RecipeViewOut actualViewOut = addRecipeCall(viewIn).as(author).ok();
assertThat(actualViewOut).isEqualTo(viewOut);
}
@Test
void addRecipe_invalidRecipeIn_badRequest() {
RecipeViewGroup group = recipeViewGroup();
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
save(slots(group).add(author));
RecipeViewIn viewIn = invalid(group.toRecipeViewIn());
when(profileIdentity.id()).thenReturn(noSubscriptionMono());
when(addRecipe.addRecipe(any(), anyLong())).thenReturn(noSubscriptionMono());
addRecipeCall(viewIn).as(author).badRequest();
}
@Test
void getRecipe_existentRecipeId_okRecipeViewOut() {
RecipeViewGroup group = recipeViewGroup();
save(slots(group));
RecipeViewOut viewOut = group.toRecipeViewOut();
long recipeId = group.recipe().id();
when(getRecipe.getRecipe(recipeId)).thenReturn(some(viewOut));
RecipeViewOut actualViewOut = getRecipeCall(recipeId).ok();
assertThat(actualViewOut).isEqualTo(viewOut);
}
@Test
void getRecipe_nonExistentRecipeId_notFound() {
var err = new GetRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
when(getRecipe.getRecipe(freeRecipeId)).thenReturn(err(err));
getRecipeCall(freeRecipeId).notFound();
}
@Test
void updateRecipe_existentRecipeId$validRecipeViewIn$author_ok() {
RecipeViewGroup group = recipeViewGroup();
AccountSlot author = accountSlot().profile(group.recipe().author()).blank();
save(slots(group).add(author));
RecipeSlot recipe = group.recipe();
long profileId = recipe.author().id();
RecipeViewIn viewIn = group.toRecipeViewIn();
RecipeView view = group.toRecipeView();
RecipeViewOut viewOut = group.toRecipeViewOut();
when(profileIdentity.id()).thenReturn(some(profileId));
when(updateRecipe.updateRecipe(recipe.id(), viewIn, profileId)).thenReturn(some(view));
RecipeViewOut actualViewOut = updateRecipeCall(recipe.id(), viewIn).as(author).ok();
assertThat(actualViewOut).isEqualTo(viewOut);
}
@Test
void updateRecipe_nonExistentRecipeId$validRecipeViewIn_notFound() {
RecipeViewGroup group = recipeViewGroup();
AccountSlot account = accountSlot().profile(group.recipe().author()).blank();
save(slots(group).add(account));
long profileId = group.recipe().author().id();
RecipeViewIn viewIn = group.toRecipeViewIn();
var err = new UpdateRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
when(profileIdentity.id()).thenReturn(some(profileId));
when(updateRecipe.updateRecipe(freeRecipeId, viewIn, profileId)).thenReturn(err(err));
updateRecipeCall(freeRecipeId, viewIn).as(account).notFound();
}
@Test
void updateRecipe_existentRecipeId$ValidRecipeViewIn$notAuthor_forbidden() {
AccountSlot author = accountSlot(profiles.ada).blank();
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
RecipeViewGroup group = recipeViewGroup(recipe);
save(slots(group).add(author, notAuthor));
RecipeViewIn viewIn = group.toRecipeViewIn();
long recipeId = group.recipe().id();
var err = new UpdateRecipe.Forbidden(new RecipeService.UpdateRecipeForbidden(recipeId));
when(profileIdentity.id()).thenReturn(some(notAuthor.profile().id()));
when(updateRecipe.updateRecipe(recipeId, viewIn, notAuthor.profile().id())).thenReturn(err(err));
updateRecipeCall(recipeId, viewIn).as(notAuthor).forbidden();
}
@Test
void updateRecipe_invalidRecipeViewIn_badRequest() {
RecipeViewGroup group = recipeViewGroup();
AccountSlot account = accountSlot().profile(group.recipe().author()).blank();
RecipeViewIn viewIn = invalid(group.toRecipeViewIn());
when(profileIdentity.id()).thenReturn(noSubscriptionMono());
when(updateRecipe.updateRecipe(anyLong(), any(), anyLong())).thenReturn(noSubscriptionMono());
updateRecipeCall(freeRecipeId, viewIn).as(account).badRequest();
}
@Test
void removeRecipe_existentRecipeId$author_ok() {
AccountSlot author = accountSlot().blank();
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
save(slot(author).add(recipe));
when(profileIdentity.id()).thenReturn(some(author.profile().id()));
when(removeRecipe.removeRecipe(recipe.id(), author.profile().id())).thenReturn(some(true));
removeRecipeCall(recipe.id()).as(author).ok();
}
@Test
void removeRecipe_nonExistentRecipeId_notFound() {
AccountSlot account = accountSlot().blank();
save(slot(account));
var err = new RemoveRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId));
when(profileIdentity.id()).thenReturn(some(account.profile().id()));
when(removeRecipe.removeRecipe(freeRecipeId, account.profile().id())).thenReturn(err(err));
removeRecipeCall(freeRecipeId).as(account).notFound();
}
@Test
void removeRecipe_existentRecipeId$notAuthor_forbidden() {
AccountSlot author = accountSlot(profiles.ada).blank();
AccountSlot notAuthor = accountSlot(profiles.bea).blank();
RecipeSlot recipe = recipeSlot().author(author.profile()).blank();
save(slot(recipe).add(author, notAuthor));
var err = new RemoveRecipe.Forbidden(new RecipeService.RemoveRecipeForbidden(recipe.id()));
when(profileIdentity.id()).thenReturn(some(notAuthor.profile().id()));
when(removeRecipe.removeRecipe(recipe.id(), notAuthor.profile().id())).thenReturn(err(err));
removeRecipeCall(recipe.id()).as(notAuthor).forbidden();
}
@Test
void findRecipes_someCategoryName$noRecipeName_okAllRecipeViewOut() {
String categoryName = categorySlotWithLinkCount(2).blank().name();
String recipeName = null;
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
save(combinedSlots(allGroups));
List<RecipeViewGroup> groups = allGroups.stream()
.filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName)))
.sorted(orderByRecipeChangedAtDesc())
.toList();
List<RecipeViewOut> viewsOut = groups.stream().map(toRecipeViewOut).toList();
when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut));
List<RecipeViewOut> actualViewsOut = findRecipesCall().category(categoryName).pagination(pagination).ok();
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
}
@Test
void findRecipes_noCategoryName$someRecipeName_okAllRecipeViewOut() {
String categoryName = null;
String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra
List<RecipeViewGroup> allGroups = recipeViewGroups().toList();
save(combinedSlots(allGroups));
List<RecipeViewGroup> groups = allGroups.stream()
.filter(containsRecipeName(recipeName))
.sorted(orderByRecipeChangedAtDesc())
.toList();
List<RecipeViewOut> viewsOut = groups.stream().map(RecipeViewGroup::toRecipeViewOut).toList();
when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut));
List<RecipeViewOut> actualViewsOut = findRecipesCall().recipe(recipeName).pagination(pagination).ok();
assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut);
}
@Test
void findRecipes_invalidLimit_badRequest() {
when(findRecipes.findRecipes(any(), any(), any())).thenReturn(noSubscriptionMono());
for (long limit : List.of(MIN_LIMIT - 1, MAX_LIMIT + 1)) {
findRecipesCall().recipe("any").limit(limit).offset(0L).badRequest();
}
}
@Test
void findRecipes_invalidOffset_badRequestProblemDetail() {
when(findRecipes.findRecipes(any(), any(), any())).thenReturn(noSubscriptionMono());
findRecipesCall().recipe("any").limit(MAX_LIMIT).offset(-1L).badRequest();
}
}

View File

@@ -0,0 +1,70 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.core.profile.ProfileSlot;
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeQueries;
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import static eu.bitfield.recipes.test.InvalidEntity.*;
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static org.mockito.Mockito.*;
public class RemoveRecipeTest implements RecipeQueries {
RecipeService serv = mock(RecipeService.class);
RemoveRecipe removeRecipe = new RemoveRecipe(serv);
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Test
void removeRecipe_nonExistentRecipeId_errorNotFound() {
long recipeId = freeId;
ProfileSlot profile = profileSlot().blank();
save(slot(profile));
when(serv.removeRecipe(recipeId, profile.id())).thenReturn(err(new RecipeService.RecipeNotFound(recipeId)));
removeRecipe.removeRecipe(recipeId, profile.id())
.as(StepVerifier::create)
.verifyError(RemoveRecipe.NotFound.class);
}
@Test
void removeRecipe_notAuthorProfileId_errorForbidden() {
ProfileSlot author = profileSlot(profiles.ada).blank();
ProfileSlot notAuthor = profileSlot(profiles.bea).blank();
RecipeSlot recipe = recipeSlot().author(author).blank();
save(slots(author, notAuthor).add(recipe));
when(serv.removeRecipe(recipe.id(), notAuthor.id())).thenReturn(err(new RemoveRecipeForbidden(recipe.id())));
removeRecipe.removeRecipe(recipe.id(), notAuthor.id())
.as(StepVerifier::create)
.verifyError(RemoveRecipe.Forbidden.class);
}
@Test
void removeRecipe_anyRecipeIn_removedRecipe() {
RecipeSlot recipe = recipeSlot().blank();
save(slot(recipe));
when(serv.removeRecipe(recipe.id(), recipe.author().id())).thenReturn(some(true));
removeRecipe.removeRecipe(recipe.id(), recipe.author().id())
.as(StepVerifier::create)
.expectNext(true)
.verifyComplete();
}
}

View File

@@ -0,0 +1,131 @@
package eu.bitfield.recipes.api.recipe;
import eu.bitfield.recipes.core.category.CategoryService;
import eu.bitfield.recipes.core.ingredient.Ingredient;
import eu.bitfield.recipes.core.ingredient.IngredientService;
import eu.bitfield.recipes.core.link.LinkRecCatService;
import eu.bitfield.recipes.core.recipe.Recipe;
import eu.bitfield.recipes.core.recipe.RecipeIn;
import eu.bitfield.recipes.core.recipe.RecipeService;
import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound;
import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden;
import eu.bitfield.recipes.core.step.StepService;
import eu.bitfield.recipes.test.core.category.CategoryLayer;
import eu.bitfield.recipes.test.core.ingredient.IngredientLayer;
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.core.profile.ProfileSlot;
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
import eu.bitfield.recipes.test.core.step.StepLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries;
import eu.bitfield.recipes.util.Chronology;
import eu.bitfield.recipes.view.recipe.RecipeView;
import eu.bitfield.recipes.view.recipe.RecipeViewIn;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import java.time.Instant;
import java.util.List;
import static eu.bitfield.recipes.test.InvalidEntity.*;
import static eu.bitfield.recipes.test.core.profile.ProfileTags.*;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static eu.bitfield.recipes.util.TestUtils.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
public class UpdateRecipeTest implements RecipeViewQueries {
RecipeService recipeServ = mock(RecipeService.class);
CategoryService categoryServ = mock(CategoryService.class);
LinkRecCatService linkServ = mock(LinkRecCatService.class);
StepService stepServ = mock(StepService.class);
IngredientService ingredientServ = mock(IngredientService.class);
Chronology time = mock(Chronology.class);
UpdateRecipe updateRecipe = new UpdateRecipe(recipeServ, categoryServ, linkServ, stepServ, ingredientServ, time);
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate RecipeLayer recipeLayer = layers.recipeLayer();
@Delegate CategoryLayer categoryLayer = layers.categoryLayer();
@Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer();
@Delegate IngredientLayer ingredientLayer = layers.ingredientLayer();
@Delegate StepLayer stepLayer = layers.stepLayer();
@Test
void updateRecipe_nonExistentRecipeId_errorNotFound() {
RecipeViewGroup group = recipeViewGroup();
save(slots(group));
long recipeId = freeId;
long profileId = group.recipe().author().id();
RecipeViewIn viewIn = group.toRecipeViewIn();
Instant changedAt = realTime().now();
var err = new RecipeNotFound(recipeId);
when(time.now()).thenReturn(changedAt);
when(recipeServ.updateRecipe(recipeId, profileId, viewIn.recipe(), changedAt)).thenReturn(err(err));
when(categoryServ.addCategories(any())).thenReturn(noSubscriptionMono());
when(linkServ.updateLinks(anyLong(), any())).thenReturn(noSubscriptionMono());
when(stepServ.updateSteps(anyLong(), any())).thenReturn(noSubscriptionMono());
when(ingredientServ.updateIngredients(anyLong(), any())).thenReturn(noSubscriptionMono());
updateRecipe.updateRecipe(recipeId, viewIn, profileId)
.as(StepVerifier::create)
.verifyError(UpdateRecipe.NotFound.class);
}
@Test
void updateRecipe_notAuthorProfileId_errorForbidden() {
ProfileSlot author = profileSlot(profiles.ada).blank();
ProfileSlot notAuthor = profileSlot(profiles.bea).blank();
RecipeSlot recipe = recipeSlot().author(author).blank();
RecipeViewGroup group = recipeViewGroup(recipe);
save(slots(group).add(author, notAuthor));
RecipeViewIn viewIn = group.toRecipeViewIn();
RecipeIn recipeIn = viewIn.recipe();
Instant changedAt = realTime().now();
var err = new UpdateRecipeForbidden(recipe.id());
when(time.now()).thenReturn(changedAt);
when(recipeServ.updateRecipe(recipe.id(), notAuthor.id(), recipeIn, changedAt)).thenReturn(err(err));
when(categoryServ.addCategories(any())).thenReturn(noSubscriptionMono());
when(linkServ.updateLinks(anyLong(), any())).thenReturn(noSubscriptionMono());
when(stepServ.updateSteps(anyLong(), any())).thenReturn(noSubscriptionMono());
when(ingredientServ.updateIngredients(anyLong(), any())).thenReturn(noSubscriptionMono());
updateRecipe.updateRecipe(recipe.id(), viewIn, notAuthor.id())
.as(StepVerifier::create)
.verifyError(UpdateRecipe.Forbidden.class);
}
@Test
void updateRecipe_anyRecipeIn_recipeView() {
RecipeViewGroup group = recipeViewGroup();
save(slots(group));
RecipeView view = group.toRecipeView();
RecipeViewIn viewIn = group.toRecipeViewIn();
Recipe recipe = view.recipe();
List<Ingredient> ingredients = view.ingredients();
long recipeId = recipe.id();
long profileId = recipe.authorProfileId();
Instant changedAt = recipe.changedAt();
when(time.now()).thenReturn(changedAt);
when(recipeServ.updateRecipe(recipeId, profileId, viewIn.recipe(), changedAt)).thenReturn(some(recipe));
when(categoryServ.addCategories(viewIn.categories())).thenReturn(some(view.categories()));
when(linkServ.updateLinks(recipeId, view.categories())).thenReturn(some(view.links()));
when(stepServ.updateSteps(recipeId, viewIn.steps())).thenReturn(some(view.steps()));
when(ingredientServ.updateIngredients(recipeId, viewIn.ingredients())).thenReturn(some(ingredients));
updateRecipe.updateRecipe(recipeId, viewIn, profileId)
.as(StepVerifier::create)
.expectNext(view)
.verifyComplete();
}
}

View File

@@ -0,0 +1,53 @@
package eu.bitfield.recipes.auth;
import eu.bitfield.recipes.auth.email.EmailAddress;
import eu.bitfield.recipes.core.account.AccountService;
import eu.bitfield.recipes.test.core.account.AccountLayer;
import eu.bitfield.recipes.test.core.account.AccountQueries;
import eu.bitfield.recipes.test.core.account.AccountSlot;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static org.mockito.Mockito.*;
public class AccountPrincipalServiceTest implements AccountQueries {
AccountService accountServ = mock(AccountService.class);
AccountPrincipalService serv = new AccountPrincipalService(accountServ);
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate AccountLayer accountLayer = layers.accountLayer();
@Test
void findByUserName_existentAccountEmail_accountPrincipal() {
AccountSlot account = accountSlot().blank();
save(slot(account));
String address = account.emailIn().address().toUpperCase();
var principal = new AccountPrincipal(account.saved());
when(accountServ.getAccount(account.email())).thenReturn(some(account.saved()));
serv.findByUsername(address)
.as(StepVerifier::create)
.expectNext(principal)
.verifyComplete();
}
@Test
void findByUserName_nonExistentAccountEmail_none() {
EmailAddress email = unusedEmail;
when(accountServ.getAccount(email)).thenReturn(none());
serv.findByUsername(email.address())
.as(StepVerifier::create)
.verifyComplete();
}
}

View File

@@ -0,0 +1,140 @@
package eu.bitfield.recipes.core.account;
import eu.bitfield.recipes.config.DatabaseConfiguration;
import eu.bitfield.recipes.core.profile.ProfileRepository;
import eu.bitfield.recipes.test.core.account.AccountLayer;
import eu.bitfield.recipes.test.core.account.AccountQueries;
import eu.bitfield.recipes.test.core.account.AccountSlot;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.data.AsyncLayerFactory;
import eu.bitfield.recipes.test.data.AsyncRootStorage;
import eu.bitfield.recipes.util.Transaction;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.reactive.TransactionalOperator;
import java.util.List;
import static eu.bitfield.recipes.test.AsyncPersistence.*;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static eu.bitfield.recipes.util.To.*;
import static org.assertj.core.api.Assertions.*;
import static reactor.function.TupleUtils.*;
@Import(DatabaseConfiguration.class) @DataR2dbcTest
class AccountRepositoryTest implements AccountQueries {
final AccountRepository repo;
final @Delegate AsyncRootStorage rootStorage;
final @Delegate ProfileLayer profileLayer;
final @Delegate AccountLayer accountLayer;
final Transaction transaction;
@Autowired
public AccountRepositoryTest(
ProfileRepository profileRepo,
AccountRepository accountRepo,
TransactionalOperator transactionalOperator)
{
this.repo = accountRepo;
this.rootStorage = new AsyncRootStorage();
var layers = new AsyncLayerFactory(rootStorage);
this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo));
this.accountLayer = layers.accountLayer(asyncPersistence(accountRepo));
this.transaction = new Transaction(transactionalOperator);
}
@Test
void addAccount_validInitialAccount_savedAccount() {
AccountSlot account = accountSlot().blank();
@NoArgsConstructor @AllArgsConstructor @Setter @Accessors(fluent = true)
class Check {
Account initial, actual, saved;
}
var checks = supply(() -> new Check().initial(account.initial()))
.zipWhen((Check c) -> repo.addAccount(c.initial), Check::actual)
.zipWhen((Check c) -> repo.findById(c.actual.id()), Check::saved);
init(slot(account))
.then(checks)
.as(transaction::rollbackVerify)
.assertNext((Check c) -> {
assertThat(c.actual.id()).isNotZero();
assertThat(c.actual).usingRecursiveComparison().ignoringFields(Account.Fields.id).isEqualTo(c.initial);
assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved);
})
.verifyComplete();
}
@Test
void addAccount_invalidAccountNonExistentProfileId_errorForeignKeyConstraintViolation() {
AccountSlot account = accountSlot().blank();
var checks = defer(() -> repo.addAccount(account.initial()));
invalidate(account.profile());
init(slot(account))
.then(checks)
.as(transaction::rollbackVerify)
.verifyError(DataIntegrityViolationException.class);
}
@Test
void accountByEmail_usedAccountEmail_account() {
List<AccountSlot> accounts = accountSlots().limit(2).map(to::blank).toList();
AccountSlot account = accounts.getFirst();
var checks = defer(() -> repo.accountByEmail(account.email()))
.zipWhen((Account actual) -> some(account.saved()));
save(slots(accounts))
.then(checks)
.as(transaction::rollbackVerify)
.assertNext(consumer((Account actual, Account saved) -> {
assertThat(actual).usingRecursiveComparison().isEqualTo(saved);
}))
.verifyComplete();
}
@Test
void accountByEmail_unusedEmail_none() {
var checks = repo.accountByEmail(unusedEmail);
checks.as(transaction::rollbackVerify)
.verifyComplete();
}
@Test
void isEmailUsed_usedAccountEmail_true() {
AccountSlot account = accountSlot().blank();
var checks = defer(() -> repo.isEmailUsed(account.email()));
save(slot(account))
.then(checks)
.as(transaction::rollbackVerify)
.expectNext(true)
.verifyComplete();
}
@Test
void isEmailUsed_unusedAccountEmail_false() {
var checks = repo.isEmailUsed(unusedEmail);
checks.as(transaction::rollbackVerify)
.expectNext(false)
.verifyComplete();
}
}

View File

@@ -0,0 +1,80 @@
package eu.bitfield.recipes.core.account;
import eu.bitfield.recipes.auth.email.EmailAddress;
import eu.bitfield.recipes.test.core.account.AccountLayer;
import eu.bitfield.recipes.test.core.account.AccountQueries;
import eu.bitfield.recipes.test.core.account.AccountSlot;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.data.LayerFactory;
import eu.bitfield.recipes.test.data.RootStorage;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import reactor.test.StepVerifier;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static org.mockito.Mockito.*;
public class AccountServiceTest implements AccountQueries {
AccountRepository repo = mock(AccountRepository.class);
PasswordEncoder encoder = mock(PasswordEncoder.class);
AccountService serv = new AccountService(repo, encoder);
@Delegate RootStorage rootStorage = new RootStorage();
LayerFactory layers = new LayerFactory(rootStorage);
@Delegate ProfileLayer profileLayer = layers.profileLayer();
@Delegate AccountLayer accountLayer = layers.accountLayer();
@Test
public void addAccount_existentProfileId_savedAccount() {
AccountSlot account = accountSlot().blank();
save(slot(account));
when(encoder.encode(account.passwordIn().raw())).thenReturn(account.password().encoded());
when(repo.addAccount(account.initial())).thenReturn(some(account.saved()));
serv.addAccount(account.profile().id(), account.email(), account.password())
.as(StepVerifier::create)
.expectNext(account.saved())
.verifyComplete();
}
@Test
public void getAccount_existentAccountEmail_account() {
AccountSlot account = accountSlot().blank();
save(slot(account));
when(repo.accountByEmail(account.email())).thenReturn(some(account.saved()));
serv.getAccount(account.email())
.as(StepVerifier::create)
.expectNext(account.saved()).verifyComplete();
}
@Test
public void checkEmailAvailable_usedEmail_errorEmailAlreadyInUse() {
AccountSlot account = accountSlot().blank();
save(slot(account));
when(repo.isEmailUsed(account.email())).thenReturn(some(true));
serv.checkEmail(account.email())
.as(StepVerifier::create)
.verifyError(AccountService.EmailAlreadyInUse.class);
}
@Test
public void checkEmailAvailable_unusedEmail_none() {
EmailAddress email = unusedEmail;
when(repo.isEmailUsed(email)).thenReturn(some(false));
serv.checkEmail(email)
.as(StepVerifier::create)
.expectNext(email)
.verifyComplete();
}
}

View File

@@ -0,0 +1,138 @@
package eu.bitfield.recipes.core.category;
import eu.bitfield.recipes.config.DatabaseConfiguration;
import eu.bitfield.recipes.core.link.LinkRecCatRepository;
import eu.bitfield.recipes.core.profile.ProfileRepository;
import eu.bitfield.recipes.core.recipe.RecipeRepository;
import eu.bitfield.recipes.test.core.category.CategoryLayer;
import eu.bitfield.recipes.test.core.category.CategorySlot;
import eu.bitfield.recipes.test.core.link.LinkRecCatLayer;
import eu.bitfield.recipes.test.core.link.LinkRecCatQueries;
import eu.bitfield.recipes.test.core.link.LinkRecCatSlot;
import eu.bitfield.recipes.test.core.profile.ProfileLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeLayer;
import eu.bitfield.recipes.test.core.recipe.RecipeSlot;
import eu.bitfield.recipes.test.data.AsyncLayerFactory;
import eu.bitfield.recipes.test.data.AsyncRootStorage;
import eu.bitfield.recipes.util.Transaction;
import lombok.experimental.Delegate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.reactive.TransactionalOperator;
import java.util.List;
import static eu.bitfield.recipes.test.AsyncPersistence.*;
import static eu.bitfield.recipes.test.data.EntitySlots.*;
import static eu.bitfield.recipes.util.AsyncUtils.*;
import static eu.bitfield.recipes.util.To.*;
import static org.assertj.core.api.Assertions.*;
import static reactor.function.TupleUtils.*;
@Import(DatabaseConfiguration.class) @DataR2dbcTest
class CategoryRepositoryTest implements LinkRecCatQueries {
final CategoryRepository repo;
final @Delegate AsyncRootStorage rootStorage;
final @Delegate ProfileLayer profileLayer;
final @Delegate RecipeLayer recipeLayer;
final @Delegate CategoryLayer categoryLayer;
final @Delegate LinkRecCatLayer linkLayer;
final Transaction transaction;
@Autowired
public CategoryRepositoryTest(
ProfileRepository profileRepo,
CategoryRepository categoryRepo,
RecipeRepository recipeRepo,
LinkRecCatRepository linkRepo,
TransactionalOperator transactionalOp)
{
this.repo = categoryRepo;
this.rootStorage = new AsyncRootStorage();
var layerFactory = new AsyncLayerFactory(rootStorage);
this.profileLayer = layerFactory.profileLayer(asyncPersistence(profileRepo));
this.categoryLayer = layerFactory.categoryLayer(asyncPersistence(categoryRepo));
this.recipeLayer = layerFactory.recipeLayer(asyncPersistence(recipeRepo));
this.linkLayer = layerFactory.linkRecCatLayer(asyncPersistence(linkRepo));
this.transaction = new Transaction(transactionalOp);
}
@Test
void addCategory_validInitialCategory_savedCategory() {
CategorySlot category = categorySlot().blank();
var checks = defer(() -> repo.addCategory(category.initial()))
.zipWhen((Category actual) -> repo.findById(actual.id()));
init(slot(category))
.then(checks)
.as(transaction::rollbackVerify)
.assertNext(consumer((Category actual, Category saved) -> {
Category initial = category.initial();
assertThat(actual.id()).isNotZero();
assertThat(actual).usingRecursiveComparison().ignoringFields(Category.Fields.id).isEqualTo(initial);
assertThat(actual).usingRecursiveComparison().isEqualTo(saved);
}))
.verifyComplete();
}
@Test
void addCategory_initialCategoryExistentName_errorUniqueNameConstraintException() {
CategorySlot category = categorySlot().blank();
var checks = defer(() -> repo.addCategory(category.initial()));
save(slot(category))
.then(checks)
.as(transaction::rollbackVerify)
.verifyError(DataIntegrityViolationException.class);
}
@Test
void categoriesOutByRecipeId_existentRecipeId_categoriesOut() {
List<RecipeSlot> recipes = recipeSlotsWithLinkCounts(2, 1).map(to::blank).toList();
RecipeSlot recipe = recipes.getFirst();
List<CategorySlot> categories = linkedCategorySlots(recipe).map(to::blank).toList();
List<LinkRecCatSlot> allLinks = recipes.stream().flatMap(this::linkRecCatSlots).map(to::blank).toList();
var checks = defer(() -> repo.categoriesByRecipeId(recipe.id()).collectList());
save(slots(allLinks))
.then(checks)
.as(transaction::rollbackVerify)
.assertNext((List<Category> actual) -> {
List<Category> saved = categories.stream().map(to::saved).toList();
assertThat(actual).containsExactlyInAnyOrderElementsOf(saved);
})
.verifyComplete();
}
@Test
void categoryIdByName_existentCategoryName_categoryId() {
List<CategorySlot> categories = categorySlots().limit(2).map(to::blank).toList();
CategorySlot category = categories.getFirst();
var checks = defer(() -> repo.categoryIdByName(category.name()));
save(slot(category))
.then(checks)
.as(transaction::rollbackVerify)
.assertNext((Long actualId) -> {
assertThat(actualId).isEqualTo(category.id());
})
.verifyComplete();
}
@Test
void categoryIdByName_nonExistentCategoryName_none() {
var checks = repo.categoryIdByName("not_a_category");
checks.as(transaction::rollbackVerify)
.verifyComplete();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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