diff --git a/src/main/java/com/brennaswitzer/cookbook/config/GraphQLConfig.java b/src/main/java/com/brennaswitzer/cookbook/config/GraphQLConfig.java index e192dcd8..0268b9f4 100644 --- a/src/main/java/com/brennaswitzer/cookbook/config/GraphQLConfig.java +++ b/src/main/java/com/brennaswitzer/cookbook/config/GraphQLConfig.java @@ -1,6 +1,8 @@ package com.brennaswitzer.cookbook.config; import com.brennaswitzer.cookbook.graphql.support.OffsetConnectionCursorCoercing; +import com.brennaswitzer.cookbook.security.UserPrincipal; +import com.brennaswitzer.cookbook.util.UserPrincipalAccess; import graphql.ExceptionWhileDataFetching; import graphql.ExecutionResult; import graphql.execution.AsyncExecutionStrategy; @@ -88,7 +90,9 @@ public GraphQLScalarType cursor(OffsetConnectionCursorCoercing coercing) { } @Bean - public GraphQLServletContextBuilder graphQLServletContextBuilder(DataLoaderRegistry dataLoadRegistry) { + public GraphQLServletContextBuilder graphQLServletContextBuilder( + DataLoaderRegistry dataLoadRegistry, + UserPrincipalAccess principalAccess) { return new GraphQLServletContextBuilder() { @Override public GraphQLKickstartContext build() { @@ -100,15 +104,19 @@ public GraphQLKickstartContext build(HttpServletRequest request, HttpServletResp Map map = new HashMap<>(); map.put(HttpServletRequest.class, request); map.put(HttpServletResponse.class, response); + // Building the context happens on the main HTTP thread, so + // Spring Security's holder will always be available. Query + // execution may become asynchronous, rendering Spring's context + // unavailable. So grab the Principal now - though not the User + // object - so it's available to resolvers, without creating any + // mandates about transaction/session demarcation. + map.put(UserPrincipal.class, principalAccess.getUserPrincipal()); return new DefaultGraphQLContext(dataLoadRegistry, map); } @Override public GraphQLKickstartContext build(Session session, HandshakeRequest handshakeRequest) { - Map map = new HashMap<>(); - map.put(Session.class, session); - map.put(HandshakeRequest.class, handshakeRequest); - return new DefaultGraphQLContext(dataLoadRegistry, map); + throw new UnsupportedOperationException("GoBrenna's doesn't yet speak websockets. Again."); } }; } diff --git a/src/main/java/com/brennaswitzer/cookbook/config/SecurityConfig.java b/src/main/java/com/brennaswitzer/cookbook/config/SecurityConfig.java index 6a679101..b7cfb901 100644 --- a/src/main/java/com/brennaswitzer/cookbook/config/SecurityConfig.java +++ b/src/main/java/com/brennaswitzer/cookbook/config/SecurityConfig.java @@ -102,6 +102,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/", "/error", "/favicon.ico", + "/favicon.svg", "/shared/*/*", "/*/*.png", "/*/*.gif", diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/FavoriteType.java b/src/main/java/com/brennaswitzer/cookbook/domain/FavoriteType.java index b88b7e35..549f21b2 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/FavoriteType.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/FavoriteType.java @@ -1,11 +1,13 @@ package com.brennaswitzer.cookbook.domain; import lombok.Getter; +import lombok.ToString; +@ToString +@Getter public enum FavoriteType { RECIPE(Recipe.class); - @Getter private final String key; FavoriteType(Class clazz) { @@ -20,4 +22,13 @@ public boolean matches(String key) { return this.key.equals(key); } + public static FavoriteType parse(String key) { + for (var v : values()) + if (v.matches(key)) return v; + throw new IllegalArgumentException(String.format( + "No '%s' %s", + key, + FavoriteType.class.getSimpleName())); + } + } diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/Owned.java b/src/main/java/com/brennaswitzer/cookbook/domain/Owned.java index ea9646e8..fc4580d3 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/Owned.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/Owned.java @@ -1,5 +1,7 @@ package com.brennaswitzer.cookbook.domain; +import com.brennaswitzer.cookbook.security.UserPrincipal; + /** * @author bboisvert */ @@ -9,4 +11,16 @@ public interface Owned { void setOwner(User owner); + default boolean isOwner(User user) { + return isOwner(user.getId()); + } + + default boolean isOwner(UserPrincipal up) { + return isOwner(up.getId()); + } + + default boolean isOwner(long userId) { + return userId == getOwner().getId(); + } + } diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/User.java b/src/main/java/com/brennaswitzer/cookbook/domain/User.java index 359cc656..86bc3a50 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/User.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/User.java @@ -8,31 +8,23 @@ import lombok.Getter; import lombok.Setter; +@Setter +@Getter @Entity @Table(name = "users", uniqueConstraints = { @UniqueConstraint(columnNames = "email") }) public class User extends BaseEntity { - @Getter - @Setter private String name; - @Getter - @Setter private String email; - @Getter - @Setter private String imageUrl; @Enumerated(EnumType.STRING) - @Getter - @Setter private AuthProvider provider; - @Getter - @Setter private String providerId; public User() { @@ -48,7 +40,7 @@ public String toString() { return super.toString() + "(" + email + ")"; } - public boolean isOwnerOf(Owned owned) { - return equals(owned.getOwner()); + public boolean owns(Owned owned) { + return owned.isOwner(this); } } diff --git a/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavorite.java b/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavorite.java new file mode 100644 index 00000000..8fb4891a --- /dev/null +++ b/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavorite.java @@ -0,0 +1,5 @@ +package com.brennaswitzer.cookbook.graphql.loaders; + +import com.brennaswitzer.cookbook.domain.FavoriteType; + +public record IsFavorite(long ownerId, FavoriteType favType, long objectId) {} diff --git a/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavoriteBatchLoader.java b/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavoriteBatchLoader.java new file mode 100644 index 00000000..d9ccec65 --- /dev/null +++ b/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavoriteBatchLoader.java @@ -0,0 +1,67 @@ +package com.brennaswitzer.cookbook.graphql.loaders; + +import com.brennaswitzer.cookbook.domain.Favorite; +import com.brennaswitzer.cookbook.domain.FavoriteType; +import com.brennaswitzer.cookbook.repositories.FavoriteRepository; +import com.google.common.annotations.VisibleForTesting; +import org.dataloader.BatchLoader; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.mapping; + +@Component +public class IsFavoriteBatchLoader implements BatchLoader { + + @Autowired + private FavoriteRepository repo; + + private record OwnerType(long ownerId, FavoriteType favType) { + + public static OwnerType of(IsFavorite key) { + return new OwnerType(key.ownerId(), key.favType()); + } + + public static OwnerType of(Favorite fav) { + return new OwnerType(fav.getOwner().getId(), FavoriteType.parse(fav.getObjectType())); + } + + } + + @Override + public CompletionStage> load(List keys) { + return CompletableFuture.supplyAsync(() -> loadInternal(keys)); + } + + @VisibleForTesting + List loadInternal(List keys) { + var favIdsByOwnerType = keys.stream() + .collect(groupingBy(OwnerType::of, + mapping(IsFavorite::objectId, + Collectors.toSet()))) + .entrySet() + .stream() + .map(e -> repo.findByOwnerIdAndObjectTypeAndObjectIdIn( + e.getKey().ownerId(), + e.getKey().favType().getKey(), + e.getValue())) + .mapMulti(Iterable::forEach) + .collect(groupingBy(OwnerType::of, + mapping(Favorite::getObjectId, + Collectors.toSet()))); + return keys.stream() + .map(k -> favIdsByOwnerType.getOrDefault( + OwnerType.of(k), + Set.of()) + .contains(k.objectId())) + .toList(); + } + +} diff --git a/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/RecipeIsFavoriteBatchLoader.java b/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/RecipeIsFavoriteBatchLoader.java deleted file mode 100644 index f7394212..00000000 --- a/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/RecipeIsFavoriteBatchLoader.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.brennaswitzer.cookbook.graphql.loaders; - -import com.brennaswitzer.cookbook.domain.FavoriteType; -import com.brennaswitzer.cookbook.domain.Recipe; -import com.brennaswitzer.cookbook.domain.User; -import com.brennaswitzer.cookbook.repositories.FavoriteRepository; -import com.brennaswitzer.cookbook.util.UserPrincipalAccess; -import com.google.common.annotations.VisibleForTesting; -import org.dataloader.BatchLoader; -import org.hibernate.Hibernate; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; - -@Component -public class RecipeIsFavoriteBatchLoader implements BatchLoader { - - @Autowired - private FavoriteRepository repo; - - @Autowired - private UserPrincipalAccess principalAccess; - - @Override - public CompletionStage> load(List recipes) { - // Graphql will complete the future on a thread from its own pool, - // outside Spring's context, so retrieve the owner synchronously. It'll - // also be outside the Hibernate session, so eagerly load it. The owners - // of the Recipes should already have been loaded. - User owner = (User) Hibernate.unproxy(principalAccess.getUser()); - return CompletableFuture.supplyAsync(() -> loadInternal(owner, recipes)); - } - - @VisibleForTesting - List loadInternal(User owner, List recipes) { - List ownedRecipeIds = recipes.stream() - .filter(owner::isOwnerOf) - .map(Recipe::getId) - .toList(); - Set favoriteIds; - if (ownedRecipeIds.isEmpty()) { - favoriteIds = Collections.emptySet(); - } else { - favoriteIds = new HashSet<>(); - for (var f : repo.findByOwnerAndObjectTypeAndObjectIdIn( - owner, - FavoriteType.RECIPE.getKey(), - ownedRecipeIds)) { - favoriteIds.add(f.getObjectId()); - } - } - return recipes.stream() - .map(Recipe::getId) - .map(favoriteIds::contains) - .toList(); - } - -} diff --git a/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/UnitOfMeasureBatchLoader.java b/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/UnitOfMeasureBatchLoader.java index 1caacd9e..2c9f3bbb 100644 --- a/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/UnitOfMeasureBatchLoader.java +++ b/src/main/java/com/brennaswitzer/cookbook/graphql/loaders/UnitOfMeasureBatchLoader.java @@ -7,9 +7,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.function.Function; @@ -23,13 +24,20 @@ public class UnitOfMeasureBatchLoader implements BatchLoader> load(List uomIds) { - Map byId = repo.findAllById(new HashSet<>(uomIds)).stream() - .collect(Collectors.toMap(Identified::getId, - Function.identity())); - return CompletableFuture.completedStage( - uomIds.stream() - .map(byId::get) - .toList()); + return CompletableFuture.supplyAsync(() -> { + Set nonNullIds = uomIds.stream() + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map byId; + if (nonNullIds.isEmpty()) byId = Map.of(); + else byId = repo.findAllById(nonNullIds) + .stream() + .collect(Collectors.toMap(Identified::getId, + Function.identity())); + return uomIds.stream() + .map(byId::get) + .toList(); + }); } } diff --git a/src/main/java/com/brennaswitzer/cookbook/graphql/resolvers/RecipeResolver.java b/src/main/java/com/brennaswitzer/cookbook/graphql/resolvers/RecipeResolver.java index 41700d6f..edd21ddb 100644 --- a/src/main/java/com/brennaswitzer/cookbook/graphql/resolvers/RecipeResolver.java +++ b/src/main/java/com/brennaswitzer/cookbook/graphql/resolvers/RecipeResolver.java @@ -1,12 +1,15 @@ package com.brennaswitzer.cookbook.graphql.resolvers; +import com.brennaswitzer.cookbook.domain.FavoriteType; import com.brennaswitzer.cookbook.domain.IngredientRef; import com.brennaswitzer.cookbook.domain.Photo; import com.brennaswitzer.cookbook.domain.PlanItemStatus; import com.brennaswitzer.cookbook.domain.PlannedRecipeHistory; import com.brennaswitzer.cookbook.domain.Recipe; -import com.brennaswitzer.cookbook.graphql.loaders.RecipeIsFavoriteBatchLoader; +import com.brennaswitzer.cookbook.graphql.loaders.IsFavorite; +import com.brennaswitzer.cookbook.graphql.loaders.IsFavoriteBatchLoader; import com.brennaswitzer.cookbook.mapper.LabelMapper; +import com.brennaswitzer.cookbook.security.UserPrincipal; import com.brennaswitzer.cookbook.services.favorites.FetchFavorites; import graphql.kickstart.tools.GraphQLResolver; import graphql.schema.DataFetchingEnvironment; @@ -53,8 +56,12 @@ public List labels(Recipe recipe) { public CompletableFuture favorite(Recipe recipe, DataFetchingEnvironment env) { - return env.getDataLoader(RecipeIsFavoriteBatchLoader.class.getName()) - .load(recipe); + // Spring Security's principal is copied to the GraphQL context when it + // is safe. It's not safe to interrogate Spring here, though it does + // work in some situations. + UserPrincipal up = env.getGraphQlContext().get(UserPrincipal.class); + return env.getDataLoader(IsFavoriteBatchLoader.class.getName()) + .load(new IsFavorite(up.getId(), FavoriteType.RECIPE, recipe.getId())); } public Photo photo(Recipe recipe) { diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/FavoriteRepository.java b/src/main/java/com/brennaswitzer/cookbook/repositories/FavoriteRepository.java index ea14f018..9ca95eb7 100644 --- a/src/main/java/com/brennaswitzer/cookbook/repositories/FavoriteRepository.java +++ b/src/main/java/com/brennaswitzer/cookbook/repositories/FavoriteRepository.java @@ -1,31 +1,30 @@ package com.brennaswitzer.cookbook.repositories; import com.brennaswitzer.cookbook.domain.Favorite; -import com.brennaswitzer.cookbook.domain.User; import org.springframework.stereotype.Repository; -import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.Set; @Repository public interface FavoriteRepository extends BaseEntityRepository { - List findByOwner(User owner); + List findByOwnerId(Long ownerId); - List findByOwnerAndObjectType(User owner, - String objectType); + List findByOwnerIdAndObjectType(Long ownerId, + String objectType); - Optional findByOwnerAndObjectTypeAndObjectId(User owner, - String objectType, - Long objectId); - - Iterable findByOwnerAndObjectTypeAndObjectIdIn(User owner, + Optional findByOwnerIdAndObjectTypeAndObjectId(Long ownerId, String objectType, - Collection objectIds); + Long objectId); + + Iterable findByOwnerIdAndObjectTypeAndObjectIdIn(Long ownerId, + String objectType, + Set objectIds); - int deleteByOwnerAndObjectTypeAndObjectId(User owner, - String objectType, - Long objectId); + int deleteByOwnerIdAndObjectTypeAndObjectId(Long ownerId, + String objectType, + Long objectId); } diff --git a/src/main/java/com/brennaswitzer/cookbook/services/favorites/FetchFavorites.java b/src/main/java/com/brennaswitzer/cookbook/services/favorites/FetchFavorites.java index 7d80d2c8..09094805 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/favorites/FetchFavorites.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/favorites/FetchFavorites.java @@ -7,9 +7,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.Set; @Service @Transactional @@ -22,22 +22,22 @@ public class FetchFavorites { private UserPrincipalAccess principalAccess; public List all() { - return repo.findByOwner(principalAccess.getUser()); + return repo.findByOwnerId(principalAccess.getId()); } public List byType(String objectType) { - return repo.findByOwnerAndObjectType(principalAccess.getUser(), + return repo.findByOwnerIdAndObjectType(principalAccess.getId(), objectType); } public Optional byObject(String objectType, Long objectId) { - return repo.findByOwnerAndObjectTypeAndObjectId(principalAccess.getUser(), + return repo.findByOwnerIdAndObjectTypeAndObjectId(principalAccess.getId(), objectType, objectId); } - public Iterable byObjects(String objectType, Collection objectIds) { - return repo.findByOwnerAndObjectTypeAndObjectIdIn(principalAccess.getUser(), + public Iterable byObjects(String objectType, Set objectIds) { + return repo.findByOwnerIdAndObjectTypeAndObjectIdIn(principalAccess.getId(), objectType, objectIds); } diff --git a/src/main/java/com/brennaswitzer/cookbook/services/favorites/UpdateFavorites.java b/src/main/java/com/brennaswitzer/cookbook/services/favorites/UpdateFavorites.java index a9e08fd2..8eb0d42e 100644 --- a/src/main/java/com/brennaswitzer/cookbook/services/favorites/UpdateFavorites.java +++ b/src/main/java/com/brennaswitzer/cookbook/services/favorites/UpdateFavorites.java @@ -1,7 +1,6 @@ package com.brennaswitzer.cookbook.services.favorites; import com.brennaswitzer.cookbook.domain.Favorite; -import com.brennaswitzer.cookbook.domain.User; import com.brennaswitzer.cookbook.repositories.FavoriteRepository; import com.brennaswitzer.cookbook.util.UserPrincipalAccess; import org.springframework.beans.factory.annotation.Autowired; @@ -19,13 +18,12 @@ public class UpdateFavorites { private UserPrincipalAccess principalAccess; public Favorite ensureFavorite(String objectType, Long objectId) { - User user = principalAccess.getUser(); - return repo.findByOwnerAndObjectTypeAndObjectId(user, + return repo.findByOwnerIdAndObjectTypeAndObjectId(principalAccess.getId(), objectType, objectId) .orElseGet(() -> { Favorite fav = new Favorite(); - fav.setOwner(user); + fav.setOwner(principalAccess.getUser()); fav.setObjectType(objectType); fav.setObjectId(objectId); return repo.save(fav); @@ -33,7 +31,7 @@ public Favorite ensureFavorite(String objectType, Long objectId) { } public boolean ensureNotFavorite(String objectType, Long objectId) { - return 0 < repo.deleteByOwnerAndObjectTypeAndObjectId(principalAccess.getUser(), + return 0 < repo.deleteByOwnerIdAndObjectTypeAndObjectId(principalAccess.getId(), objectType, objectId); } diff --git a/src/main/resources/public/favicon.ico b/src/main/resources/public/favicon.ico index f8d95a4a..ec47ffff 100644 Binary files a/src/main/resources/public/favicon.ico and b/src/main/resources/public/favicon.ico differ diff --git a/src/main/resources/public/favicon.svg b/src/main/resources/public/favicon.svg new file mode 100644 index 00000000..12bf0560 --- /dev/null +++ b/src/main/resources/public/favicon.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html index cb4341a9..e5368002 100644 --- a/src/main/resources/templates/error.html +++ b/src/main/resources/templates/error.html @@ -2,6 +2,9 @@ Server Error + + +

Server Error

diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html index 9cf76423..8c310a9d 100644 --- a/src/main/resources/templates/error/404.html +++ b/src/main/resources/templates/error/404.html @@ -2,6 +2,9 @@ Not Found + + +

Not Found

diff --git a/src/test/java/com/brennaswitzer/cookbook/domain/FavoriteTypeTest.java b/src/test/java/com/brennaswitzer/cookbook/domain/FavoriteTypeTest.java new file mode 100644 index 00000000..c215a289 --- /dev/null +++ b/src/test/java/com/brennaswitzer/cookbook/domain/FavoriteTypeTest.java @@ -0,0 +1,30 @@ +package com.brennaswitzer.cookbook.domain; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FavoriteTypeTest { + + @Test + void matches() { + assertTrue(FavoriteType.RECIPE.matches("Recipe")); + assertFalse(FavoriteType.RECIPE.matches("recipe")); + assertFalse(FavoriteType.RECIPE.matches("RECIPE")); + } + + @Test + void parse() { + assertEquals(FavoriteType.RECIPE, FavoriteType.parse("Recipe")); + assertThrows(IllegalArgumentException.class, + () -> FavoriteType.parse("")); + assertThrows(IllegalArgumentException.class, + () -> FavoriteType.parse("recipe")); + assertThrows(IllegalArgumentException.class, + () -> FavoriteType.parse("RECIPE")); + } + +} diff --git a/src/test/java/com/brennaswitzer/cookbook/domain/OwnedTest.java b/src/test/java/com/brennaswitzer/cookbook/domain/OwnedTest.java new file mode 100644 index 00000000..124bb132 --- /dev/null +++ b/src/test/java/com/brennaswitzer/cookbook/domain/OwnedTest.java @@ -0,0 +1,72 @@ +package com.brennaswitzer.cookbook.domain; + +import com.brennaswitzer.cookbook.security.UserPrincipal; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class OwnedTest { + + @Data + @AllArgsConstructor + private static class TestOwned implements Owned { + + private User owner; + + } + + @Test + void isOwner_long() { + var owned = new TestOwned(mockUser(123L)); + assertTrue(owned.isOwner(123L)); + assertFalse(owned.isOwner(456L)); + } + + @Test + void isOwner_principal() { + var owned = new TestOwned(mockUser(123L)); + assertTrue(owned.isOwner(mockPrincipal(123L))); + assertFalse(owned.isOwner(mockPrincipal(456L))); + } + + @Test + void isOwner_user() { + var owned = new TestOwned(mockUser(123L)); + assertTrue(owned.isOwner(mockUser(123L))); + assertFalse(owned.isOwner(mockUser(456L))); + } + + @Test + void user_owns() { + var user = spy(new User()); + var owned = mock(Owned.class); + when(owned.isOwner(any(User.class))).thenReturn(false); + + assertFalse(user.owns(owned)); + + verify(owned).isOwner(user); + verifyNoMoreInteractions(owned); + } + + private static UserPrincipal mockPrincipal(long id) { + var mock = mock(UserPrincipal.class); + when(mock.getId()).thenReturn(id); + return mock; + } + + private static User mockUser(long id) { + var mock = mock(User.class); + when(mock.getId()).thenReturn(id); + return mock; + } + +} diff --git a/src/test/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavoriteBatchLoaderTest.java b/src/test/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavoriteBatchLoaderTest.java new file mode 100644 index 00000000..6f2d003c --- /dev/null +++ b/src/test/java/com/brennaswitzer/cookbook/graphql/loaders/IsFavoriteBatchLoaderTest.java @@ -0,0 +1,65 @@ +package com.brennaswitzer.cookbook.graphql.loaders; + +import com.brennaswitzer.cookbook.domain.Favorite; +import com.brennaswitzer.cookbook.domain.FavoriteType; +import com.brennaswitzer.cookbook.domain.User; +import com.brennaswitzer.cookbook.repositories.FavoriteRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class IsFavoriteBatchLoaderTest { + + @InjectMocks + private IsFavoriteBatchLoader loader; + + @Mock + private FavoriteRepository repo; + + @Test + void happyPath() { + User user = mockUser(456L); + Favorite favTwo = mock(Favorite.class); + when(favTwo.getOwner()).thenReturn(user); + when(favTwo.getObjectType()).thenReturn(FavoriteType.RECIPE.getKey()); + when(favTwo.getObjectId()).thenReturn(2L); + when(repo.findByOwnerIdAndObjectTypeAndObjectIdIn(any(), any(), any())) + .thenReturn(List.of(favTwo)); + + List result = loader.loadInternal(List.of( + new IsFavorite(123L, FavoriteType.RECIPE, 1L), + new IsFavorite(456L, FavoriteType.RECIPE, 2L), + new IsFavorite(123L, FavoriteType.RECIPE, 3L))); + + assertEquals(List.of(false, true, false), result); + verify(repo).findByOwnerIdAndObjectTypeAndObjectIdIn( + 123L, + FavoriteType.RECIPE.getKey(), + Set.of(1L, 3L)); + verify(repo).findByOwnerIdAndObjectTypeAndObjectIdIn( + 456L, + FavoriteType.RECIPE.getKey(), + Set.of(2L)); + verifyNoMoreInteractions(repo); + } + + private static User mockUser(@SuppressWarnings("SameParameterValue") long id) { + var mock = mock(User.class); + when(mock.getId()).thenReturn(id); + return mock; + } + +} diff --git a/src/test/java/com/brennaswitzer/cookbook/graphql/loaders/RecipeIsFavoriteBatchLoaderTest.java b/src/test/java/com/brennaswitzer/cookbook/graphql/loaders/RecipeIsFavoriteBatchLoaderTest.java deleted file mode 100644 index 8406d5aa..00000000 --- a/src/test/java/com/brennaswitzer/cookbook/graphql/loaders/RecipeIsFavoriteBatchLoaderTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.brennaswitzer.cookbook.graphql.loaders; - -import com.brennaswitzer.cookbook.domain.Favorite; -import com.brennaswitzer.cookbook.domain.FavoriteType; -import com.brennaswitzer.cookbook.domain.Recipe; -import com.brennaswitzer.cookbook.domain.User; -import com.brennaswitzer.cookbook.repositories.FavoriteRepository; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class RecipeIsFavoriteBatchLoaderTest { - - @InjectMocks - private RecipeIsFavoriteBatchLoader loader; - - @Mock - private FavoriteRepository repo; - - @Test - void happyPath() { - User owner = mock(User.class); - when(owner.isOwnerOf(any())).thenReturn(true, true, false); - Recipe one = mockRecipe(1L); // owned, but not fav - Recipe two = mockRecipe(2L); // owned and fav - Recipe three = mockRecipe(3L); // not owned - Favorite favTwo = mock(Favorite.class); - when(favTwo.getObjectId()).thenReturn(2L); - when(repo.findByOwnerAndObjectTypeAndObjectIdIn(any(), any(), any())) - .thenReturn(List.of(favTwo)); - - List result = loader.loadInternal(owner, - List.of(one, two, three)); - - assertEquals(List.of(false, true, false), result); - verify(repo).findByOwnerAndObjectTypeAndObjectIdIn( - owner, - FavoriteType.RECIPE.getKey(), - List.of(1L, 2L)); - } - - @Test - void noOwnedRecipes() { - User owner = mock(User.class); - when(owner.isOwnerOf(any())).thenReturn(false); - - List result = loader.loadInternal(owner, - List.of(mock(Recipe.class), - mock(Recipe.class))); - - assertEquals(List.of(false, false), result); - verifyNoInteractions(repo); - } - - private static Recipe mockRecipe(long id) { - Recipe mock = mock(Recipe.class); - when(mock.getId()).thenReturn(id); - return mock; - } - -}