From d38edb74b01300992c9e3896a1e93b54a0f8966b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=A4nel?= Date: Tue, 12 Aug 2025 17:25:52 +0200 Subject: [PATCH] feat!: initial prototype --- .gitignore | 43 +++ build.gradle.kts | 42 +++ docker-compose.yml | 17 + docker/db.Dockerfile | 3 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 251 ++++++++++++++ gradlew.bat | 94 ++++++ settings.gradle.kts | 1 + .../bitfield/recipes/RecipesApplication.java | 12 + .../recipes/api/ErrorResponseHandling.java | 113 +++++++ .../recipes/api/account/AccountEndpoint.java | 29 ++ .../recipes/api/account/RegisterAccount.java | 48 +++ .../recipes/api/recipe/AddRecipe.java | 43 +++ .../recipes/api/recipe/FindRecipes.java | 30 ++ .../recipes/api/recipe/GetRecipe.java | 48 +++ .../recipes/api/recipe/RecipeEndpoint.java | 81 +++++ .../recipes/api/recipe/RemoveRecipe.java | 36 +++ .../recipes/api/recipe/UpdateRecipe.java | 67 ++++ .../recipes/auth/AccountPrincipal.java | 18 ++ .../recipes/auth/AccountPrincipalService.java | 28 ++ .../recipes/auth/ProfileIdentity.java | 5 + .../recipes/auth/ProfileIdentityAccess.java | 18 ++ .../recipes/auth/email/EmailAddress.java | 28 ++ .../recipes/auth/email/EmailAddressIn.java | 45 +++ .../recipes/auth/password/Password.java | 36 +++ .../recipes/auth/password/PasswordIn.java | 34 ++ .../recipes/config/CacheConfiguration.java | 28 ++ .../recipes/config/DatabaseConfiguration.java | 19 ++ .../recipes/config/SecurityConfiguration.java | 65 ++++ .../recipes/core/account/Account.java | 21 ++ .../recipes/core/account/AccountIn.java | 19 ++ .../core/account/AccountRepository.java | 35 ++ .../recipes/core/account/AccountService.java | 52 +++ .../recipes/core/account/ToAccountIn.java | 5 + .../recipes/core/category/Category.java | 22 ++ .../recipes/core/category/CategoryIn.java | 6 + .../recipes/core/category/CategoryOut.java | 9 + .../core/category/CategoryRepository.java | 25 ++ .../core/category/CategoryService.java | 31 ++ .../recipes/core/category/ToCategoryIn.java | 5 + .../recipes/core/category/ToCategoryOut.java | 5 + .../recipes/core/ingredient/Ingredient.java | 21 ++ .../recipes/core/ingredient/IngredientIn.java | 7 + .../core/ingredient/IngredientOut.java | 5 + .../core/ingredient/IngredientRepository.java | 93 ++++++ .../core/ingredient/IngredientService.java | 35 ++ .../core/ingredient/ToIngredientIn.java | 7 + .../core/ingredient/ToIngredientOut.java | 6 + .../recipes/core/link/LinkRecCat.java | 20 ++ .../core/link/LinkRecCatRepository.java | 21 ++ .../recipes/core/link/LinkRecCatService.java | 32 ++ .../recipes/core/profile/Profile.java | 14 + .../core/profile/ProfileRepository.java | 10 + .../recipes/core/profile/ProfileService.java | 18 ++ .../bitfield/recipes/core/recipe/Recipe.java | 27 ++ .../recipes/core/recipe/RecipeIn.java | 11 + .../recipes/core/recipe/RecipeOut.java | 13 + .../recipes/core/recipe/RecipeRepository.java | 61 ++++ .../recipes/core/recipe/RecipeService.java | 73 +++++ .../recipes/core/recipe/ToRecipeIn.java | 6 + .../recipes/core/recipe/ToRecipeOut.java | 5 + .../eu/bitfield/recipes/core/step/Step.java | 21 ++ .../eu/bitfield/recipes/core/step/StepIn.java | 7 + .../bitfield/recipes/core/step/StepOut.java | 5 + .../recipes/core/step/StepRepository.java | 31 ++ .../recipes/core/step/StepService.java | 32 ++ .../bitfield/recipes/core/step/ToStepIn.java | 6 + .../bitfield/recipes/core/step/ToStepOut.java | 5 + .../recipes/log/EventMatcherEvaluator.java | 39 +++ .../eu/bitfield/recipes/package-info.java | 4 + .../eu/bitfield/recipes/util/AsyncUtils.java | 74 +++++ .../eu/bitfield/recipes/util/Chronology.java | 11 + .../recipes/util/CollectionUtils.java | 33 ++ .../java/eu/bitfield/recipes/util/Entity.java | 8 + .../eu/bitfield/recipes/util/ErrorUtils.java | 25 ++ .../java/eu/bitfield/recipes/util/Id.java | 6 + .../eu/bitfield/recipes/util/Pagination.java | 3 + .../recipes/view/recipe/RecipeView.java | 39 +++ .../recipes/view/recipe/RecipeViewIn.java | 25 ++ .../recipes/view/recipe/RecipeViewOut.java | 23 ++ .../recipes/view/recipe/ToRecipeViewIn.java | 5 + .../recipes/view/recipe/ToRecipeViewOut.java | 5 + .../view/registration/RegistrationView.java | 9 + src/main/resources/application.yaml | 31 ++ src/main/resources/logback.xml | 26 ++ src/main/resources/schema.sql | 49 +++ .../java/eu/bitfield/recipes/api/APITest.java | 178 ++++++++++ .../api/account/AccountEndpointTest.java | 83 +++++ .../api/account/RegisterAccountTest.java | 70 ++++ .../recipes/api/recipe/AddRecipeTest.java | 71 ++++ .../recipes/api/recipe/FindRecipesTest.java | 94 ++++++ .../recipes/api/recipe/GetRecipeTest.java | 73 +++++ .../api/recipe/RecipeEndpointTest.java | 276 ++++++++++++++++ .../recipes/api/recipe/RemoveRecipeTest.java | 70 ++++ .../recipes/api/recipe/UpdateRecipeTest.java | 131 ++++++++ .../auth/AccountPrincipalServiceTest.java | 53 +++ .../core/account/AccountRepositoryTest.java | 140 ++++++++ .../core/account/AccountServiceTest.java | 80 +++++ .../core/category/CategoryRepositoryTest.java | 138 ++++++++ .../core/category/CategoryServiceTest.java | 77 +++++ .../ingredient/IngredientRepositoryTest.java | 182 +++++++++++ .../ingredient/IngredientServiceTest.java | 92 ++++++ .../core/link/LinkRecCatRepositoryTest.java | 167 ++++++++++ .../core/link/LinkRecCatServiceTest.java | 76 +++++ .../core/profile/ProfileRepositoryTest.java | 58 ++++ .../core/profile/ProfileServiceTest.java | 39 +++ .../core/recipe/RecipeRepositoryTest.java | 305 ++++++++++++++++++ .../core/recipe/RecipeServiceTest.java | 220 +++++++++++++ .../recipes/core/step/StepRepositoryTest.java | 183 +++++++++++ .../recipes/core/step/StepServiceTest.java | 92 ++++++ .../recipes/test/AsyncPersistence.java | 21 ++ .../bitfield/recipes/test/InvalidEntity.java | 5 + .../eu/bitfield/recipes/test/Persistence.java | 31 ++ .../eu/bitfield/recipes/test/api/APICall.java | 227 +++++++++++++ .../bitfield/recipes/test/api/APICalls.java | 35 ++ .../test/api/account/RegisterAccountCall.java | 29 ++ .../test/api/recipe/AddRecipeCall.java | 36 +++ .../test/api/recipe/FindRecipesCall.java | 49 +++ .../test/api/recipe/GetRecipeCall.java | 29 ++ .../test/api/recipe/RemoveRecipeCall.java | 38 +++ .../test/api/recipe/UpdateRecipeCall.java | 45 +++ .../eu/bitfield/recipes/test/auth/Auth.java | 13 + .../bitfield/recipes/test/auth/BasicAuth.java | 11 + .../eu/bitfield/recipes/test/auth/ToAuth.java | 6 + .../test/core/account/AccountActions.java | 14 + .../test/core/account/AccountLayer.java | 41 +++ .../test/core/account/AccountMask.java | 25 ++ .../test/core/account/AccountQueries.java | 73 +++++ .../test/core/account/AccountSlot.java | 60 ++++ .../test/core/account/AccountTemplate.java | 25 ++ .../test/core/account/AccountTemplates.java | 24 ++ .../test/core/category/CategoryActions.java | 13 + .../test/core/category/CategoryLayer.java | 25 ++ .../test/core/category/CategoryMask.java | 14 + .../test/core/category/CategoryQueries.java | 56 ++++ .../test/core/category/CategorySlot.java | 48 +++ .../test/core/category/CategoryTag.java | 16 + .../test/core/category/CategoryTags.java | 12 + .../test/core/category/CategoryTemplate.java | 19 ++ .../test/core/category/CategoryTemplates.java | 19 ++ .../core/ingredient/IngredientActions.java | 17 + .../test/core/ingredient/IngredientLayer.java | 32 ++ .../test/core/ingredient/IngredientMask.java | 17 + .../core/ingredient/IngredientQueries.java | 103 ++++++ .../test/core/ingredient/IngredientSlot.java | 52 +++ .../core/ingredient/IngredientTemplate.java | 21 ++ .../core/ingredient/IngredientTemplates.java | 60 ++++ .../test/core/link/CategoryLinkGroup.java | 11 + .../test/core/link/LinkRecCatActions.java | 20 ++ .../test/core/link/LinkRecCatLayer.java | 31 ++ .../test/core/link/LinkRecCatMask.java | 16 + .../test/core/link/LinkRecCatQueries.java | 156 +++++++++ .../test/core/link/LinkRecCatSlot.java | 54 ++++ .../test/core/link/LinkRecCatTemplate.java | 21 ++ .../test/core/link/LinkRecCatTemplates.java | 57 ++++ .../test/core/profile/ProfileActions.java | 9 + .../test/core/profile/ProfileLayer.java | 18 ++ .../test/core/profile/ProfileMask.java | 5 + .../test/core/profile/ProfileQueries.java | 47 +++ .../test/core/profile/ProfileSlot.java | 49 +++ .../recipes/test/core/profile/ProfileTag.java | 16 + .../test/core/profile/ProfileTags.java | 12 + .../test/core/profile/ProfileTemplate.java | 17 + .../test/core/profile/ProfileTemplates.java | 14 + .../test/core/recipe/RecipeActions.java | 18 ++ .../recipes/test/core/recipe/RecipeLayer.java | 29 ++ .../recipes/test/core/recipe/RecipeMask.java | 16 + .../test/core/recipe/RecipeQueries.java | 71 ++++ .../recipes/test/core/recipe/RecipeSlot.java | 55 ++++ .../recipes/test/core/recipe/RecipeTag.java | 16 + .../recipes/test/core/recipe/RecipeTags.java | 14 + .../test/core/recipe/RecipeTemplate.java | 22 ++ .../test/core/recipe/RecipeTemplates.java | 36 +++ .../recipes/test/core/recipe/ToRecipe.java | 13 + .../recipes/test/core/step/StepActions.java | 17 + .../recipes/test/core/step/StepLayer.java | 32 ++ .../recipes/test/core/step/StepMask.java | 18 ++ .../recipes/test/core/step/StepQueries.java | 103 ++++++ .../recipes/test/core/step/StepSlot.java | 50 +++ .../recipes/test/core/step/StepTemplate.java | 23 ++ .../recipes/test/core/step/StepTemplates.java | 65 ++++ .../test/data/AnyAsyncEntityStorage.java | 12 + .../recipes/test/data/AnyEntityStorage.java | 11 + .../recipes/test/data/AnyEntityToken.java | 7 + .../recipes/test/data/AsyncEntityStorage.java | 36 +++ .../recipes/test/data/AsyncLayerFactory.java | 85 +++++ .../recipes/test/data/AsyncRootStorage.java | 108 +++++++ .../bitfield/recipes/test/data/Context.java | 13 + .../recipes/test/data/EntitySlot.java | 29 ++ .../recipes/test/data/EntitySlots.java | 71 ++++ .../recipes/test/data/EntityStorage.java | 35 ++ .../recipes/test/data/EntityToken.java | 11 + .../bitfield/recipes/test/data/Initial.java | 5 + .../recipes/test/data/InitialEntity.java | 5 + .../recipes/test/data/LayerFactory.java | 113 +++++++ .../recipes/test/data/RootStorage.java | 77 +++++ .../eu/bitfield/recipes/test/data/Saved.java | 5 + .../recipes/test/data/SavedEntity.java | 10 + .../recipes/test/data/SlotBuilder.java | 5 + .../recipes/test/data/SlotInitializer.java | 5 + .../eu/bitfield/recipes/test/data/Tag.java | 5 + .../eu/bitfield/recipes/test/data/Tags.java | 24 ++ .../bitfield/recipes/test/data/Template.java | 3 + .../recipes/test/data/TemplateContainer.java | 22 ++ .../bitfield/recipes/test/data/Templates.java | 21 ++ .../recipes/test/data/ToEntitySlots.java | 5 + .../test/view/recipe/RecipeViewQueries.java | 104 ++++++ .../test/view/recipe/ToRecipeView.java | 7 + .../eu/bitfield/recipes/util/TestUtils.java | 28 ++ .../java/eu/bitfield/recipes/util/To.java | 63 ++++ .../eu/bitfield/recipes/util/Transaction.java | 35 ++ src/test/resources/clean.sql | 27 ++ 213 files changed, 8977 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle.kts create mode 100644 docker-compose.yml create mode 100644 docker/db.Dockerfile create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/java/eu/bitfield/recipes/RecipesApplication.java create mode 100644 src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java create mode 100644 src/main/java/eu/bitfield/recipes/api/account/AccountEndpoint.java create mode 100644 src/main/java/eu/bitfield/recipes/api/account/RegisterAccount.java create mode 100644 src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java create mode 100644 src/main/java/eu/bitfield/recipes/api/recipe/FindRecipes.java create mode 100644 src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java create mode 100644 src/main/java/eu/bitfield/recipes/api/recipe/RecipeEndpoint.java create mode 100644 src/main/java/eu/bitfield/recipes/api/recipe/RemoveRecipe.java create mode 100644 src/main/java/eu/bitfield/recipes/api/recipe/UpdateRecipe.java create mode 100644 src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java create mode 100644 src/main/java/eu/bitfield/recipes/auth/AccountPrincipalService.java create mode 100644 src/main/java/eu/bitfield/recipes/auth/ProfileIdentity.java create mode 100644 src/main/java/eu/bitfield/recipes/auth/ProfileIdentityAccess.java create mode 100644 src/main/java/eu/bitfield/recipes/auth/email/EmailAddress.java create mode 100644 src/main/java/eu/bitfield/recipes/auth/email/EmailAddressIn.java create mode 100644 src/main/java/eu/bitfield/recipes/auth/password/Password.java create mode 100644 src/main/java/eu/bitfield/recipes/auth/password/PasswordIn.java create mode 100644 src/main/java/eu/bitfield/recipes/config/CacheConfiguration.java create mode 100644 src/main/java/eu/bitfield/recipes/config/DatabaseConfiguration.java create mode 100644 src/main/java/eu/bitfield/recipes/config/SecurityConfiguration.java create mode 100644 src/main/java/eu/bitfield/recipes/core/account/Account.java create mode 100644 src/main/java/eu/bitfield/recipes/core/account/AccountIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/account/AccountRepository.java create mode 100644 src/main/java/eu/bitfield/recipes/core/account/AccountService.java create mode 100644 src/main/java/eu/bitfield/recipes/core/account/ToAccountIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/category/Category.java create mode 100644 src/main/java/eu/bitfield/recipes/core/category/CategoryIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/category/CategoryOut.java create mode 100644 src/main/java/eu/bitfield/recipes/core/category/CategoryRepository.java create mode 100644 src/main/java/eu/bitfield/recipes/core/category/CategoryService.java create mode 100644 src/main/java/eu/bitfield/recipes/core/category/ToCategoryIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/category/ToCategoryOut.java create mode 100644 src/main/java/eu/bitfield/recipes/core/ingredient/Ingredient.java create mode 100644 src/main/java/eu/bitfield/recipes/core/ingredient/IngredientIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/ingredient/IngredientOut.java create mode 100644 src/main/java/eu/bitfield/recipes/core/ingredient/IngredientRepository.java create mode 100644 src/main/java/eu/bitfield/recipes/core/ingredient/IngredientService.java create mode 100644 src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientOut.java create mode 100644 src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java create mode 100644 src/main/java/eu/bitfield/recipes/core/link/LinkRecCatRepository.java create mode 100644 src/main/java/eu/bitfield/recipes/core/link/LinkRecCatService.java create mode 100644 src/main/java/eu/bitfield/recipes/core/profile/Profile.java create mode 100644 src/main/java/eu/bitfield/recipes/core/profile/ProfileRepository.java create mode 100644 src/main/java/eu/bitfield/recipes/core/profile/ProfileService.java create mode 100644 src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java create mode 100644 src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java create mode 100644 src/main/java/eu/bitfield/recipes/core/recipe/RecipeRepository.java create mode 100644 src/main/java/eu/bitfield/recipes/core/recipe/RecipeService.java create mode 100644 src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeOut.java create mode 100644 src/main/java/eu/bitfield/recipes/core/step/Step.java create mode 100644 src/main/java/eu/bitfield/recipes/core/step/StepIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/step/StepOut.java create mode 100644 src/main/java/eu/bitfield/recipes/core/step/StepRepository.java create mode 100644 src/main/java/eu/bitfield/recipes/core/step/StepService.java create mode 100644 src/main/java/eu/bitfield/recipes/core/step/ToStepIn.java create mode 100644 src/main/java/eu/bitfield/recipes/core/step/ToStepOut.java create mode 100644 src/main/java/eu/bitfield/recipes/log/EventMatcherEvaluator.java create mode 100644 src/main/java/eu/bitfield/recipes/package-info.java create mode 100644 src/main/java/eu/bitfield/recipes/util/AsyncUtils.java create mode 100644 src/main/java/eu/bitfield/recipes/util/Chronology.java create mode 100644 src/main/java/eu/bitfield/recipes/util/CollectionUtils.java create mode 100644 src/main/java/eu/bitfield/recipes/util/Entity.java create mode 100644 src/main/java/eu/bitfield/recipes/util/ErrorUtils.java create mode 100644 src/main/java/eu/bitfield/recipes/util/Id.java create mode 100644 src/main/java/eu/bitfield/recipes/util/Pagination.java create mode 100644 src/main/java/eu/bitfield/recipes/view/recipe/RecipeView.java create mode 100644 src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewIn.java create mode 100644 src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewOut.java create mode 100644 src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewIn.java create mode 100644 src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewOut.java create mode 100644 src/main/java/eu/bitfield/recipes/view/registration/RegistrationView.java create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/logback.xml create mode 100644 src/main/resources/schema.sql create mode 100644 src/test/java/eu/bitfield/recipes/api/APITest.java create mode 100644 src/test/java/eu/bitfield/recipes/api/account/AccountEndpointTest.java create mode 100644 src/test/java/eu/bitfield/recipes/api/account/RegisterAccountTest.java create mode 100644 src/test/java/eu/bitfield/recipes/api/recipe/AddRecipeTest.java create mode 100644 src/test/java/eu/bitfield/recipes/api/recipe/FindRecipesTest.java create mode 100644 src/test/java/eu/bitfield/recipes/api/recipe/GetRecipeTest.java create mode 100644 src/test/java/eu/bitfield/recipes/api/recipe/RecipeEndpointTest.java create mode 100644 src/test/java/eu/bitfield/recipes/api/recipe/RemoveRecipeTest.java create mode 100644 src/test/java/eu/bitfield/recipes/api/recipe/UpdateRecipeTest.java create mode 100644 src/test/java/eu/bitfield/recipes/auth/AccountPrincipalServiceTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/account/AccountRepositoryTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/account/AccountServiceTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/category/CategoryRepositoryTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/category/CategoryServiceTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/ingredient/IngredientRepositoryTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/ingredient/IngredientServiceTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/link/LinkRecCatRepositoryTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/link/LinkRecCatServiceTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/profile/ProfileRepositoryTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/profile/ProfileServiceTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/recipe/RecipeRepositoryTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/recipe/RecipeServiceTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/step/StepRepositoryTest.java create mode 100644 src/test/java/eu/bitfield/recipes/core/step/StepServiceTest.java create mode 100644 src/test/java/eu/bitfield/recipes/test/AsyncPersistence.java create mode 100644 src/test/java/eu/bitfield/recipes/test/InvalidEntity.java create mode 100644 src/test/java/eu/bitfield/recipes/test/Persistence.java create mode 100644 src/test/java/eu/bitfield/recipes/test/api/APICall.java create mode 100644 src/test/java/eu/bitfield/recipes/test/api/APICalls.java create mode 100644 src/test/java/eu/bitfield/recipes/test/api/account/RegisterAccountCall.java create mode 100644 src/test/java/eu/bitfield/recipes/test/api/recipe/AddRecipeCall.java create mode 100644 src/test/java/eu/bitfield/recipes/test/api/recipe/FindRecipesCall.java create mode 100644 src/test/java/eu/bitfield/recipes/test/api/recipe/GetRecipeCall.java create mode 100644 src/test/java/eu/bitfield/recipes/test/api/recipe/RemoveRecipeCall.java create mode 100644 src/test/java/eu/bitfield/recipes/test/api/recipe/UpdateRecipeCall.java create mode 100644 src/test/java/eu/bitfield/recipes/test/auth/Auth.java create mode 100644 src/test/java/eu/bitfield/recipes/test/auth/BasicAuth.java create mode 100644 src/test/java/eu/bitfield/recipes/test/auth/ToAuth.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/account/AccountActions.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/account/AccountLayer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/account/AccountMask.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/account/AccountQueries.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/account/AccountSlot.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplate.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplates.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategoryActions.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategoryLayer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategoryMask.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategoryQueries.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategorySlot.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategoryTag.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategoryTags.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplate.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplates.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientActions.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientLayer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientMask.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientQueries.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientSlot.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplate.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplates.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/link/CategoryLinkGroup.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatActions.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatLayer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatMask.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatQueries.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatSlot.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplate.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplates.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileActions.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileLayer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileMask.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileQueries.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileSlot.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTag.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTags.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplate.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplates.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeActions.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeLayer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeMask.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeQueries.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeSlot.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTag.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTags.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplate.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplates.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/recipe/ToRecipe.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/step/StepActions.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/step/StepLayer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/step/StepMask.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/step/StepQueries.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/step/StepSlot.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/step/StepTemplate.java create mode 100644 src/test/java/eu/bitfield/recipes/test/core/step/StepTemplates.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/AnyAsyncEntityStorage.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/AnyEntityStorage.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/AnyEntityToken.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/AsyncEntityStorage.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/AsyncLayerFactory.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/AsyncRootStorage.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/Context.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/EntitySlot.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/EntitySlots.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/EntityStorage.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/EntityToken.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/Initial.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/InitialEntity.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/LayerFactory.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/RootStorage.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/Saved.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/SavedEntity.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/SlotBuilder.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/SlotInitializer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/Tag.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/Tags.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/Template.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/TemplateContainer.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/Templates.java create mode 100644 src/test/java/eu/bitfield/recipes/test/data/ToEntitySlots.java create mode 100644 src/test/java/eu/bitfield/recipes/test/view/recipe/RecipeViewQueries.java create mode 100644 src/test/java/eu/bitfield/recipes/test/view/recipe/ToRecipeView.java create mode 100644 src/test/java/eu/bitfield/recipes/util/TestUtils.java create mode 100644 src/test/java/eu/bitfield/recipes/util/To.java create mode 100644 src/test/java/eu/bitfield/recipes/util/Transaction.java create mode 100644 src/test/resources/clean.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e052b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +.gradle +build +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +#.idea/modules.xml +#.idea/jarRepositories.xml +#.idea/compiler.xml +#.idea/libraries/ +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..dfe9090 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + java + id("org.springframework.boot") version "3.5.4" + id("io.spring.dependency-management") version "1.1.7" + id("com.ryandens.javaagent-test") version "0.9.1" + id("io.freefair.lombok") version "8.14" +} + +group = "eu.bitfield" +version = "0.1" + +repositories { + mavenCentral() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.postgresql:r2dbc-postgresql") + implementation("io.projectreactor.addons:reactor-extra") + runtimeOnly("org.bouncycastle:bcpkix-jdk18on:1.81") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.projectreactor:reactor-test") + testImplementation("org.springframework.security:spring-security-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testJavaagent("net.bytebuddy:byte-buddy-agent") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e37069 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + db: + build: + context: . + dockerfile: docker/db.Dockerfile + container_name: db + restart: always + shm_size: 128mb + volumes: + - db_data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: dev_pw + POSTGRES_USER: recipes + ports: + - "5432:5432" +volumes: + db_data: diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile new file mode 100644 index 0000000..3aa10b4 --- /dev/null +++ b/docker/db.Dockerfile @@ -0,0 +1,3 @@ +# syntax=docker/dockerfile:1 +FROM postgres:latest +COPY src/main/resources/schema.sql /docker-entrypoint-initdb.d/ \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c45b17c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "recipe-api" \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/RecipesApplication.java b/src/main/java/eu/bitfield/recipes/RecipesApplication.java new file mode 100644 index 0000000..4608ffb --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/RecipesApplication.java @@ -0,0 +1,12 @@ +package eu.bitfield.recipes; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class RecipesApplication { + public static void main(String[] args) { + SpringApplication.run(RecipesApplication.class, args); + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java b/src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java new file mode 100644 index 0000000..fd3b14a --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/ErrorResponseHandling.java @@ -0,0 +1,113 @@ +package eu.bitfield.recipes.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.core.MethodParameter; +import org.springframework.http.ProblemDetail; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.validation.method.ParameterValidationResult; +import org.springframework.web.ErrorResponse; +import org.springframework.web.ErrorResponseException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.server.ServerWebInputException; + +import java.lang.reflect.Executable; +import java.lang.reflect.Parameter; +import java.util.List; + +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static org.springframework.http.HttpStatus.*; + +public interface ErrorResponseHandling { + Logger log = LoggerFactory.getLogger(ErrorResponseHandling.class); + + private static void addMethodParameter(ProblemDetail problemDetail, MethodParameter methodParam) { + Executable executable = methodParam.getExecutable(); + Parameter param = methodParam.getParameter(); + String method = executable.toGenericString(); + String argument = param.getType().getCanonicalName() + " " + param.getName(); + problemDetail.setProperty("method", method); + problemDetail.setProperty("argument", argument); + } + + @ExceptionHandler + default ErrorResponse handleError(HandlerMethodValidationException e) { + log.debug(""" + Handling {}: + method: {} + errors: {}""", + e.getClass().getName(), e.getMethod(), e.getAllErrors(), e); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason()); + + String method = e.getMethod().toGenericString(); + problemDetail.setProperty("method", method); + + MultiValueMap errors = multiValueMap(); + for (ParameterValidationResult result : e.getParameterValidationResults()) { + Parameter param = result.getMethodParameter().getParameter(); + String errorKey = param.getName(); + List errorValues = result.getResolvableErrors() + .stream() + .map(MessageSourceResolvable::getDefaultMessage) + .toList(); + errors.addAll(errorKey, errorValues); + } + problemDetail.setProperty("errors", errors); + + return ErrorResponse.builder(e, problemDetail).build(); + } + + @ExceptionHandler + default ErrorResponse handleError(WebExchangeBindException e) { + List objectErrors = e.getAllErrors(); + log.debug(""" + Handling {}: + methodParameter: {} + errors: {}""", + e.getClass().getName(), e.getMethodParameter(), e.getAllErrors(), e); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason()); + + MethodParameter methodParam = e.getMethodParameter(); + if (methodParam != null) addMethodParameter(problemDetail, methodParam); + + MultiValueMap errors = multiValueMap(); + for (ObjectError objectError : objectErrors) { + String errorKey = objectError.getObjectName(); + if (objectError instanceof FieldError fieldError) { + errorKey += "." + fieldError.getField(); + } + errors.add(errorKey, objectError.getDefaultMessage()); + } + problemDetail.setProperty("errors", errors); + + return ErrorResponse.builder(e, problemDetail).build(); + } + + @ExceptionHandler + default ErrorResponse handleError(ServerWebInputException e) { + log.debug(""" + Handling {}: + methodParameter: {}""", + e.getClass().getName(), e.getMethodParameter(), e); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(BAD_REQUEST, e.getReason()); + + MethodParameter methodParam = e.getMethodParameter(); + if (methodParam != null) addMethodParameter(problemDetail, methodParam); + + return ErrorResponse.builder(e, problemDetail).build(); + } + + @ExceptionHandler + default ErrorResponse handleError(ErrorResponseException e) { + log.debug("Handling {}", e.getClass().getName(), e); + return e; + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/account/AccountEndpoint.java b/src/main/java/eu/bitfield/recipes/api/account/AccountEndpoint.java new file mode 100644 index 0000000..27cac0c --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/account/AccountEndpoint.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.api.account; + +import eu.bitfield.recipes.api.ErrorResponseHandling; +import eu.bitfield.recipes.core.account.AccountIn; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import static eu.bitfield.recipes.util.ErrorUtils.*; +import static org.springframework.http.HttpStatus.*; + + +@RequiredArgsConstructor +@RestController @RequestMapping("/api/account") +public class AccountEndpoint implements ErrorResponseHandling { + private final RegisterAccount registerOp; + + @PostMapping("/register") + public Mono registerAccount(@RequestBody @Valid AccountIn accountIn) { + return registerOp.registerAccount(accountIn) + .onErrorMap(RegisterAccount.EmailAlreadyInUse.class, + e -> errorResponseException(e, BAD_REQUEST)) + .then(); + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/account/RegisterAccount.java b/src/main/java/eu/bitfield/recipes/api/account/RegisterAccount.java new file mode 100644 index 0000000..49d509b --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/account/RegisterAccount.java @@ -0,0 +1,48 @@ +package eu.bitfield.recipes.api.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.auth.password.Password; +import eu.bitfield.recipes.core.account.Account; +import eu.bitfield.recipes.core.account.AccountIn; +import eu.bitfield.recipes.core.account.AccountService; +import eu.bitfield.recipes.core.profile.Profile; +import eu.bitfield.recipes.core.profile.ProfileService; +import eu.bitfield.recipes.view.registration.RegistrationView; +import lombok.RequiredArgsConstructor; +import org.springframework.core.NestedRuntimeException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static reactor.function.TupleUtils.*; + +@Service +@RequiredArgsConstructor +public class RegisterAccount { + private final ProfileService profileServ; + private final AccountService accountServ; + + @Transactional + public Mono registerAccount(AccountIn accountIn) { + return Mono.defer(() -> { + return accountServ.checkEmail(EmailAddress.of(accountIn.email())) + .onErrorMap(AccountService.EmailAlreadyInUse.class, EmailAlreadyInUse::new); + }) + .zipWhen((EmailAddress __) -> profileServ.addProfile()) + .flatMap(function((EmailAddress email, Profile profile) -> { + return some(accountIn.password()) + .map(accountServ::encode) + .flatMap((Password password) -> accountServ.addAccount(profile.id(), email, password)) + .map((Account account) -> new RegistrationView(profile, account)); + })); + } + + public static class EmailAlreadyInUse extends Error { + public EmailAlreadyInUse(AccountService.EmailAlreadyInUse e) {super(e);} + } + + public static class Error extends NestedRuntimeException { + public Error(Throwable cause) {super("account registration failed", cause);} + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java b/src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java new file mode 100644 index 0000000..4dc7bbe --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/AddRecipe.java @@ -0,0 +1,43 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.category.CategoryService; +import eu.bitfield.recipes.core.ingredient.IngredientService; +import eu.bitfield.recipes.core.link.LinkRecCatService; +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.core.step.StepService; +import eu.bitfield.recipes.util.Chronology; +import eu.bitfield.recipes.view.recipe.RecipeView; +import eu.bitfield.recipes.view.recipe.RecipeViewIn; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import static reactor.core.publisher.Mono.*; +import static reactor.function.TupleUtils.*; + +@Service +@RequiredArgsConstructor +public class AddRecipe { + private final RecipeService recipeServ; + private final CategoryService categoryServ; + private final LinkRecCatService linkServ; + private final IngredientService ingredientServ; + private final StepService stepServ; + private final Chronology time; + + + @Transactional + public Mono addRecipe(RecipeViewIn detailsIn, long profileId) { + return defer(() -> recipeServ.addRecipe(profileId, detailsIn.recipe(), time.now())) + .zipWith(categoryServ.addCategories(detailsIn.categories())) + .flatMap(function((recipe, category) -> { + return zip(linkServ.addLinks(recipe.id(), category), + ingredientServ.addIngredients(recipe.id(), detailsIn.ingredients()), + stepServ.addSteps(recipe.id(), detailsIn.steps())) + .map(function((links, ingredients, steps) -> { + return new RecipeView(recipe, category, links, ingredients, steps); + })); + })); + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/FindRecipes.java b/src/main/java/eu/bitfield/recipes/api/recipe/FindRecipes.java new file mode 100644 index 0000000..62a08b4 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/FindRecipes.java @@ -0,0 +1,30 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.util.Pagination; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class FindRecipes { + private final RecipeService recipeServ; + private final GetRecipe getRecipe; + + @Transactional(readOnly = true) + public Mono> findRecipes( + @Nullable String categoryName, + @Nullable String recipeName, + Pagination pagination) + { + return recipeServ.findRecipeIds(categoryName, recipeName, pagination) + .flatMapSequential(getRecipe::getRecipe) + .collectList(); // collect to close transaction on completion + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java b/src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java new file mode 100644 index 0000000..4980eee --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/GetRecipe.java @@ -0,0 +1,48 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.category.CategoryService; +import eu.bitfield.recipes.core.ingredient.IngredientService; +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound; +import eu.bitfield.recipes.core.step.StepService; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.RequiredArgsConstructor; +import org.springframework.core.NestedRuntimeException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import static eu.bitfield.recipes.view.recipe.RecipeView.*; +import static reactor.core.publisher.Mono.*; +import static reactor.function.TupleUtils.*; + + +@Service +@RequiredArgsConstructor +public class GetRecipe { + private final RecipeService recipeServ; + private final CategoryService categoryServ; + private final IngredientService ingredientServ; + private final StepService stepServ; + + @Transactional(readOnly = true) + public Mono getRecipe(long recipeId) { + return defer(() -> recipeServ.getRecipe(recipeId).onErrorMap(RecipeNotFound.class, NotFound::new)) + .flatMap(recipe -> { + return zip(categoryServ.getCategories(recipeId), + ingredientServ.getIngredients(recipeId), + stepServ.getSteps(recipeId)) + .map(function((categories, ingredients, steps) -> { + return createRecipeViewOut(recipe, categories, ingredients, steps); + })); + }); + } + + public static class NotFound extends Error { + public NotFound(RecipeNotFound recipeNotFound) {super(recipeNotFound);} + } + + public static class Error extends NestedRuntimeException { + public Error(Throwable cause) {super("recipe details retrieval failed", cause);} + } +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/RecipeEndpoint.java b/src/main/java/eu/bitfield/recipes/api/recipe/RecipeEndpoint.java new file mode 100644 index 0000000..d8789e9 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/RecipeEndpoint.java @@ -0,0 +1,81 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.api.ErrorResponseHandling; +import eu.bitfield.recipes.auth.ProfileIdentityAccess; +import eu.bitfield.recipes.util.Pagination; +import eu.bitfield.recipes.view.recipe.RecipeView; +import eu.bitfield.recipes.view.recipe.RecipeViewIn; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.ErrorUtils.*; +import static java.util.function.Function.*; +import static org.springframework.http.HttpStatus.*; + +@RequiredArgsConstructor +@RestController @RequestMapping("/api/recipe") +public class RecipeEndpoint implements ErrorResponseHandling { + public static final long MIN_LIMIT = 1; + public static final long MAX_LIMIT = 100; + private final AddRecipe addOp; + private final GetRecipe getOp; + private final UpdateRecipe updateOp; + private final RemoveRecipe removeOp; + private final FindRecipes findOp; + private final ProfileIdentityAccess profile; + + @PostMapping("/new") + public Mono addRecipe(@RequestBody @Valid RecipeViewIn recipeViewIn) { + return profile.id() + .flatMap(profileId -> addOp.addRecipe(recipeViewIn, profileId)) + .map(RecipeView::toRecipeViewOut); + } + + @GetMapping("/{recipeId}") + public Mono getRecipe(@PathVariable long recipeId) { + return getOp.getRecipe(recipeId) + .onErrorMap(GetRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND)); + } + + @PutMapping("/{recipeId}") + public Mono updateRecipe(@PathVariable long recipeId, + @RequestBody @Valid RecipeViewIn recipeViewIn) + { + return profile.id() + .flatMap(profileId -> updateOp.updateRecipe(recipeId, recipeViewIn, profileId)) + .onErrorMap(UpdateRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND)) + .onErrorMap(UpdateRecipe.Forbidden.class, e -> errorResponseException(e, FORBIDDEN)) + .map(RecipeView::toRecipeViewOut); + } + + @DeleteMapping("/{recipeId}") + public Mono removeRecipe(@PathVariable long recipeId) { + return profile.id() + .flatMap(profileId -> removeOp.removeRecipe(recipeId, profileId)) + .onErrorMap(RemoveRecipe.NotFound.class, e -> errorResponseException(e, NOT_FOUND)) + .onErrorMap(RemoveRecipe.Forbidden.class, e -> errorResponseException(e, FORBIDDEN)) + .then(); + } + + @GetMapping("/search") + public Flux findRecipes( + @RequestParam(name = "category", required = false) @Nullable String categoryName, + @RequestParam(name = "recipe", required = false) @Nullable String recipeName, + @RequestParam(name = "limit") @Min(MIN_LIMIT) @Max(MAX_LIMIT) @Valid long limit, + @RequestParam(name = "offset") @PositiveOrZero @Valid long offset) + { + return defer(() -> findOp.findRecipes(categoryName, recipeName, new Pagination(limit, offset))) + .flatMapIterable(identity()); + } + + +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/RemoveRecipe.java b/src/main/java/eu/bitfield/recipes/api/recipe/RemoveRecipe.java new file mode 100644 index 0000000..ff19df7 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/RemoveRecipe.java @@ -0,0 +1,36 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound; +import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden; +import lombok.RequiredArgsConstructor; +import org.springframework.core.NestedRuntimeException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +@RequiredArgsConstructor +@Service +public class RemoveRecipe { + private final RecipeService recipeService; + + @Transactional + public Mono removeRecipe(long recipeId, long profileId) { + return recipeService.removeRecipe(recipeId, profileId) + .onErrorMap(RecipeNotFound.class, NotFound::new) + .onErrorMap(RemoveRecipeForbidden.class, Forbidden::new); + } + + public static final class NotFound extends Error { + public NotFound(RecipeNotFound recipeNotFound) {super(recipeNotFound);} + } + + public static class Forbidden extends Error { + public Forbidden(RemoveRecipeForbidden forbidden) {super(forbidden);} + } + + public static class Error extends NestedRuntimeException { + public Error(Throwable cause) {super("recipe details removal failed", cause);} + } + +} diff --git a/src/main/java/eu/bitfield/recipes/api/recipe/UpdateRecipe.java b/src/main/java/eu/bitfield/recipes/api/recipe/UpdateRecipe.java new file mode 100644 index 0000000..1df7066 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/api/recipe/UpdateRecipe.java @@ -0,0 +1,67 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.category.CategoryService; +import eu.bitfield.recipes.core.ingredient.IngredientService; +import eu.bitfield.recipes.core.link.LinkRecCatService; +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound; +import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden; +import eu.bitfield.recipes.core.step.StepService; +import eu.bitfield.recipes.util.Chronology; +import eu.bitfield.recipes.view.recipe.RecipeView; +import eu.bitfield.recipes.view.recipe.RecipeViewIn; +import lombok.RequiredArgsConstructor; +import org.springframework.core.NestedRuntimeException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import static reactor.core.publisher.Mono.*; +import static reactor.function.TupleUtils.*; + +@Service +@RequiredArgsConstructor +public class UpdateRecipe { + private final RecipeService recipeServ; + private final CategoryService categoryServ; + private final LinkRecCatService linkServ; + private final StepService stepServ; + private final IngredientService ingredientServ; + private final Chronology time; + + @Transactional + public Mono updateRecipe(long recipeId, RecipeViewIn detailsIn, long profileId) { + return Mono.defer(() -> { + return recipeServ.updateRecipe(recipeId, profileId, detailsIn.recipe(), time.now()) + .onErrorMap(RecipeNotFound.class, NotFound::new) + .onErrorMap(UpdateRecipeForbidden.class, Forbidden::new); + }) + .zipWhen(__ -> categoryServ.addCategories(detailsIn.categories())) + .flatMap(function((recipe, categories) -> { + return zip(linkServ.updateLinks(recipeId, categories), + stepServ.updateSteps(recipeId, detailsIn.steps()), + ingredientServ.updateIngredients(recipeId, detailsIn.ingredients())) + .map(function((links, steps, ingredients) -> { + return new RecipeView(recipe, categories, links, ingredients, steps); + })); + })); + } + + public static class NotFound extends Error { + public NotFound(RecipeNotFound recipeNotFound) { + super(recipeNotFound); + } + } + + public static class Forbidden extends Error { + public Forbidden(UpdateRecipeForbidden forbidden) { + super(forbidden); + } + } + + public static class Error extends NestedRuntimeException { + public Error(Throwable cause) { + super("recipe details update failed", cause); + } + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java b/src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java new file mode 100644 index 0000000..d99a7b2 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/AccountPrincipal.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.auth; + +import eu.bitfield.recipes.core.account.Account; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.security.core.userdetails.User; + +import java.util.List; + +@Getter @Accessors(fluent = true) +public class AccountPrincipal extends User implements ProfileIdentity { + private final long profileId; + + public AccountPrincipal(Account account) { + super(account.email(), account.passwordEncoded(), List.of()); + this.profileId = account.profileId(); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/AccountPrincipalService.java b/src/main/java/eu/bitfield/recipes/auth/AccountPrincipalService.java new file mode 100644 index 0000000..3ca4379 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/AccountPrincipalService.java @@ -0,0 +1,28 @@ +package eu.bitfield.recipes.auth; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.core.account.AccountService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import static eu.bitfield.recipes.util.AsyncUtils.*; + +@RequiredArgsConstructor +@Service @Transactional(readOnly = true) +public class AccountPrincipalService implements ReactiveUserDetailsService { + private final AccountService accountServ; + + @Override + public Mono findByUsername(String address) { + return some(address) + .mapNotNull(EmailAddressIn::of) + .map(EmailAddress::of) + .flatMap(accountServ::getAccount) + .map(AccountPrincipal::new); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/ProfileIdentity.java b/src/main/java/eu/bitfield/recipes/auth/ProfileIdentity.java new file mode 100644 index 0000000..f093ce3 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/ProfileIdentity.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.auth; + +public interface ProfileIdentity { + long profileId(); +} diff --git a/src/main/java/eu/bitfield/recipes/auth/ProfileIdentityAccess.java b/src/main/java/eu/bitfield/recipes/auth/ProfileIdentityAccess.java new file mode 100644 index 0000000..3a42f10 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/ProfileIdentityAccess.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.auth; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +public class ProfileIdentityAccess { + public Mono id() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getPrincipal) + .cast(ProfileIdentity.class) + .map(ProfileIdentity::profileId); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/email/EmailAddress.java b/src/main/java/eu/bitfield/recipes/auth/email/EmailAddress.java new file mode 100644 index 0000000..6ac2c9e --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/email/EmailAddress.java @@ -0,0 +1,28 @@ +package eu.bitfield.recipes.auth.email; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +@Getter @Accessors(fluent = true) @EqualsAndHashCode +public class EmailAddress implements Comparable { + public static Comparator order = comparing(EmailAddress::address); + private final String address; + + private EmailAddress(String address) { + this.address = address; + } + + public static EmailAddress of(EmailAddressIn emailIn) { + return new EmailAddress(emailIn.address().toLowerCase()); + } + + public int compareTo(@Nullable EmailAddress other) { + return order.compare(this, other); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/email/EmailAddressIn.java b/src/main/java/eu/bitfield/recipes/auth/email/EmailAddressIn.java new file mode 100644 index 0000000..d1c6d5d --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/email/EmailAddressIn.java @@ -0,0 +1,45 @@ +package eu.bitfield.recipes.auth.email; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.regex.Pattern; + +import static java.util.Comparator.*; + +@Getter @Accessors(fluent = true) @EqualsAndHashCode +public class EmailAddressIn implements Comparable { + private static final String EMAIL_REGEX = + "^[\\p{Alnum}!#$%&'*+/=?^_`{|}~-]+(?:\\.[\\p{Alnum}!#$%&'*+/=?^_`{|}~-]+)*@" + + "\\p{Alnum}(?:[\\p{Alnum}-]{0,61}\\p{Alnum})?(?:\\.\\p{Alnum}(?:[\\p{Alnum}-]{0,61}\\p{Alnum})?)+$"; + private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX); + // based on https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation/test + public static Comparator order = comparing(EmailAddressIn::address); + private final @JsonValue @NotNull @Email(regexp = EMAIL_REGEX) String address; + + @JsonCreator + private EmailAddressIn(String address) { + this.address = address; + } + + public static @Nullable EmailAddressIn of(String address) { + if (address == null || !EMAIL_PATTERN.matcher(address).matches()) return null; + return new EmailAddressIn(address); + } + + public static EmailAddressIn ofUnchecked(String address) { + return new EmailAddressIn(address); + } + + public int compareTo(@Nullable EmailAddressIn other) { + return order.compare(this, other); + } + +} diff --git a/src/main/java/eu/bitfield/recipes/auth/password/Password.java b/src/main/java/eu/bitfield/recipes/auth/password/Password.java new file mode 100644 index 0000000..019dde4 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/password/Password.java @@ -0,0 +1,36 @@ +package eu.bitfield.recipes.auth.password; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +@Getter @Accessors(fluent = true) @EqualsAndHashCode +public class Password implements Comparable { + public static Comparator order = comparing(Password::encoded); + private final String encoded; + + private Password(String encoded) { + this.encoded = encoded; + } + + public static Password of(Encoder encoder, PasswordIn passwordIn) { + return new Password(encoder.encode(passwordIn)); + } + + public static Password ofUnchecked(String encoded) { + return new Password(encoded); + } + + public int compareTo(@Nullable Password other) { + return order.compare(this, other); + } + + public interface Encoder { + String encode(PasswordIn password); + } +} diff --git a/src/main/java/eu/bitfield/recipes/auth/password/PasswordIn.java b/src/main/java/eu/bitfield/recipes/auth/password/PasswordIn.java new file mode 100644 index 0000000..5bbb0e3 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/auth/password/PasswordIn.java @@ -0,0 +1,34 @@ +package eu.bitfield.recipes.auth.password; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +@Getter @Accessors(fluent = true) @EqualsAndHashCode +public class PasswordIn implements Comparable { + public static final int MIN_PASSWORD_LENGTH = 8; + public static Comparator order = comparing(PasswordIn::raw); + private final @JsonValue @NotNull @Size(min = MIN_PASSWORD_LENGTH) String raw; + + @JsonCreator + private PasswordIn(String raw) { + this.raw = raw; + } + + public static PasswordIn ofUnchecked(String raw) { + return new PasswordIn(raw); + } + + public int compareTo(@Nullable PasswordIn other) { + return order.compare(this, other); + } +} diff --git a/src/main/java/eu/bitfield/recipes/config/CacheConfiguration.java b/src/main/java/eu/bitfield/recipes/config/CacheConfiguration.java new file mode 100644 index 0000000..6b77459 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/config/CacheConfiguration.java @@ -0,0 +1,28 @@ +package eu.bitfield.recipes.config; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCache; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; +import java.util.stream.Stream; + +@Configuration +public class CacheConfiguration { + @Bean + public CacheManager cacheManager() { + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(caches()); + return cacheManager; + } + + List caches() { + return Stream.of("account") + .map(ConcurrentMapCache::new) + .map(Cache.class::cast) + .toList(); + } +} diff --git a/src/main/java/eu/bitfield/recipes/config/DatabaseConfiguration.java b/src/main/java/eu/bitfield/recipes/config/DatabaseConfiguration.java new file mode 100644 index 0000000..0c8b838 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/config/DatabaseConfiguration.java @@ -0,0 +1,19 @@ +package eu.bitfield.recipes.config; + +import io.r2dbc.spi.Option; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.r2dbc.ConnectionFactoryOptionsBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +public class DatabaseConfiguration { + @Bean + public ConnectionFactoryOptionsBuilderCustomizer connectionFactoryOptionsBuilderCustomizer() { + return builder -> { +// builder.option(Option.valueOf("timeZone"), TimeZone.getTimeZone(ZoneOffset.UTC)); + builder.option(Option.valueOf("autodetectExtensions"), false); + }; + } +} diff --git a/src/main/java/eu/bitfield/recipes/config/SecurityConfiguration.java b/src/main/java/eu/bitfield/recipes/config/SecurityConfiguration.java new file mode 100644 index 0000000..5a1afbc --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/config/SecurityConfiguration.java @@ -0,0 +1,65 @@ +package eu.bitfield.recipes.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository; +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; + +import static org.springframework.http.HttpMethod.*; + +@Configuration +@EnableWebFluxSecurity +public class SecurityConfiguration { + private SecurityWebFilterChain filterChainDefaults(ServerHttpSecurity http) { + // disable session management + // https://github.com/spring-projects/spring-security/issues/6552#issuecomment-519398510 + return http.csrf(ServerHttpSecurity.CsrfSpec::disable) + .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) + .build(); + } + + @Order(Ordered.HIGHEST_PRECEDENCE) @Bean + public SecurityWebFilterChain accountEndpointFilterChain(ServerHttpSecurity httpSecurity) { + httpSecurity.securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/account/**")) + .authorizeExchange(exchange -> exchange.anyExchange().permitAll()); + return filterChainDefaults(httpSecurity); + } + + @Order(Ordered.HIGHEST_PRECEDENCE) @Bean + public SecurityWebFilterChain recipeEndpointFilterChain(ServerHttpSecurity httpSecurity) { + httpSecurity.securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/recipe/**")) + .httpBasic(Customizer.withDefaults()) + .authorizeExchange(exchange -> { + exchange.pathMatchers(GET, "/api/recipe/{recipeId:\\d+}").permitAll() + .pathMatchers(GET, "/api/recipe/search").permitAll() + .anyExchange().authenticated(); + }); + return filterChainDefaults(httpSecurity); + } + + @Bean + public SecurityWebFilterChain fallbackFilterChain(ServerHttpSecurity httpSecurity) { + httpSecurity.authorizeExchange(exchange -> exchange.anyExchange().denyAll()); + return filterChainDefaults(httpSecurity); + } + + @Bean + public PasswordEncoder passwordEncoder() { + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id + // accessed at 2025-04-22 + int saltLength = 16; + int hashLength = 32; + int parallelism = 1; + int memory = 1 << 16; // in KiB = 64 MiB + int iterations = 2; + return new Argon2PasswordEncoder(saltLength, hashLength, parallelism, memory, iterations); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/account/Account.java b/src/main/java/eu/bitfield/recipes/core/account/Account.java new file mode 100644 index 0000000..c382e8e --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/Account.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.auth.password.Password; +import eu.bitfield.recipes.util.Entity; +import lombok.With; +import lombok.experimental.FieldNameConstants; +import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; + +@With @FieldNameConstants +public record Account( + @Id long id, + long profileId, + String email, + @Column("password") String passwordEncoded +) implements Entity { + public static Account initial(long profileId, EmailAddress email, Password password) { + return new Account(0, profileId, email.address(), password.encoded()); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/account/AccountIn.java b/src/main/java/eu/bitfield/recipes/core/account/AccountIn.java new file mode 100644 index 0000000..07b3249 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/AccountIn.java @@ -0,0 +1,19 @@ +package eu.bitfield.recipes.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.auth.password.PasswordIn; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.With; +import org.springframework.validation.annotation.Validated; + +@With +@Validated +public record AccountIn( + @NotNull @Valid EmailAddressIn email, + @NotNull @Valid PasswordIn password +) implements ToAccountIn { + public AccountIn toAccountIn() { + return this; + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/core/account/AccountRepository.java b/src/main/java/eu/bitfield/recipes/core/account/AccountRepository.java new file mode 100644 index 0000000..4b3a6a8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/AccountRepository.java @@ -0,0 +1,35 @@ +package eu.bitfield.recipes.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Mono; + +interface AccountRepository extends ReactiveCrudRepository { + default Mono addAccount(Account account) { + return save(account); + } + + @Query(""" + select * from account + where email = $1 + """) + Mono query_accountByEmail(String emailAddress); + + default Mono accountByEmail(EmailAddress email) { + return query_accountByEmail(email.address()); + } + + @Query(""" + select exists( + select * from account + where email = $1 + ) + """) + Mono query_isEmailUsed(String emailAddress); + + + default Mono isEmailUsed(EmailAddress email) { + return query_isEmailUsed(email.address()); + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/core/account/AccountService.java b/src/main/java/eu/bitfield/recipes/core/account/AccountService.java new file mode 100644 index 0000000..ff264a6 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/AccountService.java @@ -0,0 +1,52 @@ +package eu.bitfield.recipes.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.auth.password.Password; +import eu.bitfield.recipes.auth.password.PasswordIn; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.core.NestedRuntimeException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import static eu.bitfield.recipes.util.AsyncUtils.*; + +@RequiredArgsConstructor +@Service @CacheConfig(cacheNames = "account") +public class AccountService { + private final AccountRepository repo; + private final PasswordEncoder encoder; + + @Transactional @CachePut(key = "email") + public Mono addAccount(long profileId, EmailAddress email, Password password) { + return supply(() -> Account.initial(profileId, email, password)).flatMap(repo::addAccount); + } + + @Transactional(readOnly = true) @Cacheable + public Mono getAccount(EmailAddress email) { + return repo.accountByEmail(email); + } + + @Transactional(readOnly = true) + public Mono checkEmail(EmailAddress email) { + return repo.isEmailUsed(email).as(errIfTrue(() -> new EmailAlreadyInUse(email))).thenReturn(email); + } + + public Password encode(PasswordIn passwordIn) { + return Password.of((PasswordIn __) -> encoder.encode(passwordIn.raw()), passwordIn); + } + + public static class EmailAlreadyInUse extends Error { + public EmailAlreadyInUse(EmailAddress email) { + super("address address '" + email.address() + "' is already in use"); + } + } + + public static class Error extends NestedRuntimeException { + public Error(String error) {super(error);} + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/account/ToAccountIn.java b/src/main/java/eu/bitfield/recipes/core/account/ToAccountIn.java new file mode 100644 index 0000000..363efdc --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/account/ToAccountIn.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.account; + +public interface ToAccountIn { + AccountIn toAccountIn(); +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/Category.java b/src/main/java/eu/bitfield/recipes/core/category/Category.java new file mode 100644 index 0000000..3b95748 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/Category.java @@ -0,0 +1,22 @@ +package eu.bitfield.recipes.core.category; + +import eu.bitfield.recipes.util.Entity; +import lombok.With; +import lombok.experimental.FieldNameConstants; +import org.springframework.data.annotation.Id; + +@With +@FieldNameConstants +public record Category( + @Id long id, + String name +) implements Entity, ToCategoryOut { + public static Category initial(String name) { + return new Category(0, name); + } + + public CategoryOut toCategoryOut() { + return new CategoryOut(name); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/category/CategoryIn.java b/src/main/java/eu/bitfield/recipes/core/category/CategoryIn.java new file mode 100644 index 0000000..25f7a95 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/CategoryIn.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.core.category; + +import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.validation.constraints.NotBlank; + +public record CategoryIn(@JsonValue @NotBlank String name) {} diff --git a/src/main/java/eu/bitfield/recipes/core/category/CategoryOut.java b/src/main/java/eu/bitfield/recipes/core/category/CategoryOut.java new file mode 100644 index 0000000..925215c --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/CategoryOut.java @@ -0,0 +1,9 @@ +package eu.bitfield.recipes.core.category; + +import com.fasterxml.jackson.annotation.JsonValue; + +public record CategoryOut(@JsonValue String name) implements ToCategoryOut { + public CategoryOut toCategoryOut() { + return this; + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/CategoryRepository.java b/src/main/java/eu/bitfield/recipes/core/category/CategoryRepository.java new file mode 100644 index 0000000..22c7900 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/CategoryRepository.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.core.category; + +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface CategoryRepository extends ReactiveCrudRepository { + default Mono addCategory(Category category) { + return save(category); + } + + @Query(""" + select * from category as c + inner join link_rec_cat as rc on c.id = rc.category_id + where rc.recipe_id = $1 + """) + Flux categoriesByRecipeId(long recipeId); + + @Query(""" + select id from category + where name = $1 + """) + Mono categoryIdByName(String name); +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/CategoryService.java b/src/main/java/eu/bitfield/recipes/core/category/CategoryService.java new file mode 100644 index 0000000..ac3d7c8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/CategoryService.java @@ -0,0 +1,31 @@ +package eu.bitfield.recipes.core.category; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static eu.bitfield.recipes.util.AsyncUtils.*; + +@RequiredArgsConstructor +@Service @Transactional(readOnly = true) +public class CategoryService { + private final CategoryRepository repo; + + @Transactional + public Mono> addCategories(List categoriesIn) { + return flux(categoriesIn).flatMapSequential(this::addCategoryIfAbsent).collectList(); + } + + public Mono> getCategories(long recipeId) { + return repo.categoriesByRecipeId(recipeId).collectList(); + } + + private Mono addCategoryIfAbsent(CategoryIn categoryIn) { + return repo.categoryIdByName(categoryIn.name()) + .map(id -> new Category(id, categoryIn.name())) + .switchIfEmpty(supply(() -> Category.initial(categoryIn.name())).flatMap(repo::addCategory)); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/ToCategoryIn.java b/src/main/java/eu/bitfield/recipes/core/category/ToCategoryIn.java new file mode 100644 index 0000000..17367d0 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/ToCategoryIn.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.category; + +public interface ToCategoryIn { + CategoryIn toCategoryIn(); +} diff --git a/src/main/java/eu/bitfield/recipes/core/category/ToCategoryOut.java b/src/main/java/eu/bitfield/recipes/core/category/ToCategoryOut.java new file mode 100644 index 0000000..d5a7b1d --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/category/ToCategoryOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.category; + +public interface ToCategoryOut { + CategoryOut toCategoryOut(); +} diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/Ingredient.java b/src/main/java/eu/bitfield/recipes/core/ingredient/Ingredient.java new file mode 100644 index 0000000..e8bb9be --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/Ingredient.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.core.ingredient; + +import eu.bitfield.recipes.util.Entity; +import lombok.With; +import lombok.experimental.FieldNameConstants; +import org.springframework.data.annotation.Id; + +@With +@FieldNameConstants +public record Ingredient( + @Id long id, + long recipeId, + String name +) implements Entity, ToIngredientOut { + public static Ingredient initial(long recipeId, String name) {return new Ingredient(0, recipeId, name);} + + public IngredientOut toIngredientOut() { + return new IngredientOut(name); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientIn.java b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientIn.java new file mode 100644 index 0000000..6a4d201 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientIn.java @@ -0,0 +1,7 @@ +package eu.bitfield.recipes.core.ingredient; + +import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.validation.constraints.NotBlank; + +public record IngredientIn(@JsonValue @NotBlank String name) { +} diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientOut.java b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientOut.java new file mode 100644 index 0000000..3f64dd8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.ingredient; + +import com.fasterxml.jackson.annotation.JsonValue; + +public record IngredientOut(@JsonValue String name) {} diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientRepository.java b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientRepository.java new file mode 100644 index 0000000..aadff32 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientRepository.java @@ -0,0 +1,93 @@ +package eu.bitfield.recipes.core.ingredient; + +import io.r2dbc.spi.Result; +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; +import io.r2dbc.spi.Statement; +import lombok.RequiredArgsConstructor; +import org.reactivestreams.Publisher; +import org.springframework.data.r2dbc.repository.Modifying; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.r2dbc.connection.R2dbcTransactionManager; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Spliterator; + +interface IngredientBatchOperations { + Flux addIngredientsBatch(Flux ingredients); +} + +public interface IngredientRepository extends ReactiveCrudRepository, IngredientBatchOperations { + // no batch support as of 2025-05-05 + // https://github.com/spring-projects/spring-data-r2dbc/issues/259 + // https://github.com/spring-projects/spring-framework/issues/33812 + default Flux addIngredients(List ingredients) { + return saveAll(ingredients); + } + + @Query(""" + select * from ingredient + where recipe_id = $1 + """) + Flux ingredientsByRecipeId(long recipeId); + + @Modifying + @Query(""" + delete from ingredient where recipe_id = $1 + """) + Mono removeIngredientsFromRecipe(long recipeId); +} + +@Repository +@RequiredArgsConstructor +class IngredientBatchOperationsImpl implements IngredientBatchOperations { + private final DatabaseClient client; + private final R2dbcTransactionManager transactionManager; + private final String addBatchSql = + """ + insert into ingredient(recipe_id, name) values ($1, $2) + """; + private final String idColumn = Ingredient.Fields.id; + + @Override + public Flux addIngredientsBatch(Flux ingredients) { + return ingredients.collectList() + .flatMapMany(this::addIngredientsBatch); + } + + public Flux addIngredientsBatch(List ingredients) { + if (ingredients.isEmpty()) { + return Flux.empty(); + } + return client.inConnectionMany(connection -> { + Statement statement = connection.createStatement(addBatchSql); + statement.returnGeneratedValues(idColumn); + Spliterator iter = ingredients.spliterator(); + iter.tryAdvance(ingredient -> bind(statement, ingredient)); + iter.forEachRemaining(ingredient -> bind(statement.add(), ingredient)); + var results = Flux.from(statement.execute()); + return Flux.fromIterable(ingredients) + .zipWith(results.concatMap(this::idFromResult), Ingredient::withId); + }); + } + + + Publisher idFromResult(Result result) { + return result.map((Row row, RowMetadata rowMetadata) -> idFromRow(row)); + } + + Long idFromRow(Row row) { + Long id = row.get(idColumn, Long.class); + return id; + } + + void bind(Statement statement, Ingredient ingredient) { + statement.bind(0, ingredient.recipeId()) + .bind(1, ingredient.name()); + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientService.java b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientService.java new file mode 100644 index 0000000..fd22410 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/IngredientService.java @@ -0,0 +1,35 @@ +package eu.bitfield.recipes.core.ingredient; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static eu.bitfield.recipes.util.AsyncUtils.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class IngredientService { + private final IngredientRepository repo; + + @Transactional + public Mono> addIngredients(long recipeId, List ingredientsIn) { + return flux(ingredientsIn) + .map(ingredientIn -> Ingredient.initial(recipeId, ingredientIn.name())) + .collectList() + .flatMapMany(repo::addIngredients) + .collectList(); + } + + public Mono> getIngredients(long recipeId) { + return repo.ingredientsByRecipeId(recipeId).collectList(); + } + + @Transactional + public Mono> updateIngredients(long recipeId, List ingredients) { + return repo.removeIngredientsFromRecipe(recipeId).then(addIngredients(recipeId, ingredients)); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientIn.java b/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientIn.java new file mode 100644 index 0000000..bedeaa5 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientIn.java @@ -0,0 +1,7 @@ +package eu.bitfield.recipes.core.ingredient; + + +public interface ToIngredientIn { + IngredientIn toIngredientIn(); +} + diff --git a/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientOut.java b/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientOut.java new file mode 100644 index 0000000..3023fed --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/ingredient/ToIngredientOut.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.core.ingredient; + +public interface ToIngredientOut { + IngredientOut toIngredientOut(); +} + diff --git a/src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java new file mode 100644 index 0000000..65c2ea8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCat.java @@ -0,0 +1,20 @@ +package eu.bitfield.recipes.core.link; + +import eu.bitfield.recipes.util.Entity; +import lombok.With; +import lombok.experimental.FieldNameConstants; +import org.springframework.data.annotation.Id; + +/// recipe category link +@With +@FieldNameConstants +public record LinkRecCat( + @Id long id, + long recipeId, + long categoryId +) implements Entity { + public static LinkRecCat initial(long recipeId, long categoryId) { + return new LinkRecCat(0, recipeId, categoryId); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatRepository.java b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatRepository.java new file mode 100644 index 0000000..6ec8ffe --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatRepository.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.core.link; + +import org.springframework.data.r2dbc.repository.Modifying; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; + +public interface LinkRecCatRepository extends ReactiveCrudRepository { + default Flux addLinks(List links) { + return saveAll(links); + } + + @Modifying + @Query(""" + delete from link_rec_cat where recipe_id = $1 + """) + Mono removeCategoriesFromRecipe(long recipeId); +} diff --git a/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatService.java b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatService.java new file mode 100644 index 0000000..862294f --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/link/LinkRecCatService.java @@ -0,0 +1,32 @@ +package eu.bitfield.recipes.core.link; + +import eu.bitfield.recipes.core.category.Category; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static eu.bitfield.recipes.util.AsyncUtils.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LinkRecCatService { + private final LinkRecCatRepository repo; + + @Transactional + public Mono> addLinks(long recipeId, List categories) { + return flux(categories) + .map(category -> LinkRecCat.initial(recipeId, category.id())) + .collectList() + .flatMapMany(repo::addLinks) + .collectList(); + } + + @Transactional + public Mono> updateLinks(long recipeId, List categories) { + return repo.removeCategoriesFromRecipe(recipeId).then(addLinks(recipeId, categories)); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/profile/Profile.java b/src/main/java/eu/bitfield/recipes/core/profile/Profile.java new file mode 100644 index 0000000..0f5b8df --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/profile/Profile.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.core.profile; + +import eu.bitfield.recipes.util.Entity; +import lombok.With; +import lombok.experimental.FieldNameConstants; +import org.springframework.data.annotation.Id; + + +@With +@FieldNameConstants +public record Profile(@Id long id) implements Entity { + public static Profile initial() {return new Profile(0);} +} + diff --git a/src/main/java/eu/bitfield/recipes/core/profile/ProfileRepository.java b/src/main/java/eu/bitfield/recipes/core/profile/ProfileRepository.java new file mode 100644 index 0000000..665fef8 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/profile/ProfileRepository.java @@ -0,0 +1,10 @@ +package eu.bitfield.recipes.core.profile; + +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Mono; + +public interface ProfileRepository extends ReactiveCrudRepository { + default Mono addProfile(Profile initialProfile) { + return save(initialProfile); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/profile/ProfileService.java b/src/main/java/eu/bitfield/recipes/core/profile/ProfileService.java new file mode 100644 index 0000000..0e9605c --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/profile/ProfileService.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.core.profile; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import static eu.bitfield.recipes.util.AsyncUtils.*; + + +@RequiredArgsConstructor +@Service @Transactional(readOnly = true) +public class ProfileService { + private final ProfileRepository repo; + + @Transactional + public Mono addProfile() {return supply(Profile::initial).flatMap(repo::addProfile);} +} diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java b/src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java new file mode 100644 index 0000000..c1be595 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/Recipe.java @@ -0,0 +1,27 @@ +package eu.bitfield.recipes.core.recipe; + +import eu.bitfield.recipes.util.Entity; +import lombok.With; +import lombok.experimental.FieldNameConstants; +import org.springframework.data.annotation.Id; + +import java.time.Instant; + +@With +@FieldNameConstants +public record Recipe( + @Id long id, + long authorProfileId, + String name, + String description, + Instant changedAt +) implements Entity, ToRecipeOut { + public static Recipe initial(long authorProfileId, String name, String description, Instant changedAt) { + return new Recipe(0, authorProfileId, name, description, changedAt); + } + + public RecipeOut toRecipeOut() { + return new RecipeOut(id, name, description, changedAt); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java new file mode 100644 index 0000000..7d9c006 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeIn.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.core.recipe; + +import jakarta.validation.constraints.NotBlank; +import lombok.With; + +@With +public record RecipeIn( + @NotBlank String name, + @NotBlank String description +) { +} diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java new file mode 100644 index 0000000..ea7335e --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeOut.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.core.recipe; + +import lombok.With; + +import java.time.Instant; + +@With +public record RecipeOut( + long id, + String name, + String description, + Instant changedAt +) {} diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/RecipeRepository.java b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeRepository.java new file mode 100644 index 0000000..ad5a9c7 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeRepository.java @@ -0,0 +1,61 @@ +package eu.bitfield.recipes.core.recipe; + +import org.springframework.data.r2dbc.repository.Modifying; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.lang.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Instant; + +public interface RecipeRepository extends ReactiveCrudRepository { + default Mono addRecipe(Recipe recipe) { + return save(recipe); + } + + default Mono recipe(long recipeId) { + return findById(recipeId); + } + + + @Modifying + @Query(""" + update recipe + set name = $2, + description = $3, + changed_at = $4 + where id = $1 + """) + Mono updateRecipe(long recipeId, String name, String description, Instant changedAt); + + @Modifying + @Query(""" + delete from recipe + where id = $1 + """) + Mono removeRecipe(long recipeId); + + @Query(""" + select id from recipe as r + where + ($2 is null or position(lower($2) in lower(r.name)) > 0) and + ($1 is null or + exists( + select * from category as c + inner join link_rec_cat as rc on c.id = rc.category_id + where lower(c.name) = lower($1) and rc.recipe_id = r.id + ) + ) + order by r.changed_at desc + limit $3 + offset $4 + """) + Flux recipeIds(@Nullable String categoryName, @Nullable String recipeName, long limit, long offset); + + @Query(""" + select author_profile_id = $2 from recipe + where id = $1 + """) + Mono canEditRecipe(long recipeId, long profileId); +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/RecipeService.java b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeService.java new file mode 100644 index 0000000..b6c2ed7 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/RecipeService.java @@ -0,0 +1,73 @@ +package eu.bitfield.recipes.core.recipe; + +import eu.bitfield.recipes.util.Pagination; +import lombok.RequiredArgsConstructor; +import org.springframework.core.NestedRuntimeException; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; + +import static eu.bitfield.recipes.util.AsyncUtils.*; + +@RequiredArgsConstructor +@Service @Transactional(readOnly = true) +public class RecipeService { + private final RecipeRepository repo; + + @Transactional + public Mono addRecipe(long authorProfileId, RecipeIn recipeIn, Instant createdAt) { + return repo.addRecipe(Recipe.initial(authorProfileId, recipeIn.name(), recipeIn.description(), createdAt)); + } + + public Mono getRecipe(long recipeId) { + return repo.recipe(recipeId).as(checkEmpty(recipeId)); + } + + public Flux findRecipeIds(@Nullable String categoryName, @Nullable String recipeName, Pagination pagination) { + return repo.recipeIds(categoryName, recipeName, pagination.limit(), pagination.offset()); + } + + public Mono updateRecipe(long recipeId, long profileId, RecipeIn recipeIn, Instant changedAt) { + return checkEdit(recipeId, profileId, () -> new UpdateRecipeForbidden(recipeId)) + .then(repo.updateRecipe(recipeId, recipeIn.name(), recipeIn.description(), changedAt)) + .then(supply(() -> new Recipe(recipeId, profileId, recipeIn.name(), recipeIn.description(), changedAt))); + } + + @Transactional + public Mono removeRecipe(long recipeId, long profileId) { + return checkEdit(recipeId, profileId, () -> new RemoveRecipeForbidden(recipeId)) + .then(repo.removeRecipe(recipeId)); + } + + private UnaryOperator> checkEmpty(long recipeId) { + return (Mono item) -> item.as(errIfEmpty(() -> new RecipeNotFound(recipeId))); + } + + private Mono checkEdit(long recipeId, long profileId, Supplier forbiddenError) { + return repo.canEditRecipe(recipeId, profileId) + .as(checkEmpty(recipeId)) + .as(errIfFalse(forbiddenError)); + } + + public static class RecipeNotFound extends Error { + public RecipeNotFound(long recipeId) {super("no such recipe (id=" + recipeId + ")");} + } + + public static class UpdateRecipeForbidden extends Error { + public UpdateRecipeForbidden(long recipeId) {super("updating recipe (id=" + recipeId + ") is not allowed");} + } + + public static class RemoveRecipeForbidden extends Error { + public RemoveRecipeForbidden(long recipeId) {super("removing recipe (id=" + recipeId + ") is not allowed");} + } + + public static class Error extends NestedRuntimeException { + public Error(String error) {super(error);} + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeIn.java b/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeIn.java new file mode 100644 index 0000000..ab7129d --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeIn.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.core.recipe; + +public interface ToRecipeIn { + RecipeIn toRecipeIn(); +} + diff --git a/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeOut.java b/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeOut.java new file mode 100644 index 0000000..73079de --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/recipe/ToRecipeOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.recipe; + +public interface ToRecipeOut { + RecipeOut toRecipeOut(); +} diff --git a/src/main/java/eu/bitfield/recipes/core/step/Step.java b/src/main/java/eu/bitfield/recipes/core/step/Step.java new file mode 100644 index 0000000..a60c8c7 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/Step.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.core.step; + +import eu.bitfield.recipes.util.Entity; +import lombok.With; +import lombok.experimental.FieldNameConstants; +import org.springframework.data.annotation.Id; + +@With +@FieldNameConstants +public record Step( + @Id long id, + long recipeId, + String name +) implements Entity, ToStepOut { + public static Step initial(long recipeId, String name) {return new Step(0, recipeId, name);} + + public StepOut toStepOut() { + return new StepOut(name); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/core/step/StepIn.java b/src/main/java/eu/bitfield/recipes/core/step/StepIn.java new file mode 100644 index 0000000..8f18329 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/StepIn.java @@ -0,0 +1,7 @@ +package eu.bitfield.recipes.core.step; + +import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.validation.constraints.NotBlank; + +public record StepIn(@JsonValue @NotBlank String name) { +} diff --git a/src/main/java/eu/bitfield/recipes/core/step/StepOut.java b/src/main/java/eu/bitfield/recipes/core/step/StepOut.java new file mode 100644 index 0000000..6797b2f --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/StepOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.step; + +import com.fasterxml.jackson.annotation.JsonValue; + +public record StepOut(@JsonValue String name) {} diff --git a/src/main/java/eu/bitfield/recipes/core/step/StepRepository.java b/src/main/java/eu/bitfield/recipes/core/step/StepRepository.java new file mode 100644 index 0000000..955fe95 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/StepRepository.java @@ -0,0 +1,31 @@ +package eu.bitfield.recipes.core.step; + +import org.springframework.data.r2dbc.repository.Modifying; +import org.springframework.data.r2dbc.repository.Query; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; + +public interface StepRepository extends ReactiveCrudRepository { + // no batch support as of 2025-05-05 + // https://github.com/spring-projects/spring-data-r2dbc/issues/259 + // https://github.com/spring-projects/spring-framework/issues/33812 + default Flux addSteps(List steps) { + return saveAll(steps); + } + + @Query(""" + select * from step + where recipe_id = $1 + order by id; + """) + Flux stepsByRecipeId(long recipeId); + + @Modifying + @Query(""" + delete from step where recipe_id = $1 + """) + Mono removeStepsFromRecipe(long recipeId); +} diff --git a/src/main/java/eu/bitfield/recipes/core/step/StepService.java b/src/main/java/eu/bitfield/recipes/core/step/StepService.java new file mode 100644 index 0000000..6ed50fe --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/StepService.java @@ -0,0 +1,32 @@ +package eu.bitfield.recipes.core.step; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static eu.bitfield.recipes.util.AsyncUtils.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StepService { + private final StepRepository repo; + + public Mono> addSteps(long recipeId, List stepsIn) { + return flux(stepsIn).map(stepIn -> Step.initial(recipeId, stepIn.name())) + .collectList() + .flatMapMany(repo::addSteps) + .collectList(); + } + + public Mono> getSteps(long recipeId) { + return repo.stepsByRecipeId(recipeId).collectList(); + } + + public Mono> updateSteps(long recipeId, List stepsIn) { + return repo.removeStepsFromRecipe(recipeId).then(addSteps(recipeId, stepsIn)); + } +} diff --git a/src/main/java/eu/bitfield/recipes/core/step/ToStepIn.java b/src/main/java/eu/bitfield/recipes/core/step/ToStepIn.java new file mode 100644 index 0000000..842593f --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/ToStepIn.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.core.step; + +public interface ToStepIn { + StepIn toStepIn(); +} + diff --git a/src/main/java/eu/bitfield/recipes/core/step/ToStepOut.java b/src/main/java/eu/bitfield/recipes/core/step/ToStepOut.java new file mode 100644 index 0000000..fb0edeb --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/core/step/ToStepOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.core.step; + +public interface ToStepOut { + StepOut toStepOut(); +} diff --git a/src/main/java/eu/bitfield/recipes/log/EventMatcherEvaluator.java b/src/main/java/eu/bitfield/recipes/log/EventMatcherEvaluator.java new file mode 100644 index 0000000..7942e4f --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/log/EventMatcherEvaluator.java @@ -0,0 +1,39 @@ +package eu.bitfield.recipes.log; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.boolex.EvaluationException; +import ch.qos.logback.core.boolex.EventEvaluatorBase; +import ch.qos.logback.core.boolex.Matcher; +import lombok.Getter; +import lombok.Setter; + +public class EventMatcherEvaluator extends EventEvaluatorBase { + @Getter @Setter String messageRegex = ".*"; + @Getter @Setter String loggerNameRegex = ".*"; + Matcher messageMatcher = new Matcher(); + Matcher loggerNameMatcher = new Matcher(); + + public boolean evaluate(ILoggingEvent event) throws EvaluationException { + return messageMatcher.matches(event.getFormattedMessage()) && loggerNameMatcher.matches(event.getLoggerName()); + } + + public void start() { + messageMatcher.setName("messageMatcher"); + messageMatcher.setRegex(messageRegex); + messageMatcher.start(); + + loggerNameMatcher.setName("messageMatcher"); + loggerNameMatcher.setRegex(loggerNameRegex); + loggerNameMatcher.start(); + + super.start(); + } + + public void stop() { + super.stop(); + + loggerNameMatcher.stop(); + + messageMatcher.stop(); + } +} \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/package-info.java b/src/main/java/eu/bitfield/recipes/package-info.java new file mode 100644 index 0000000..d696bea --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package eu.bitfield.recipes; + +import org.springframework.lang.NonNullApi; \ No newline at end of file diff --git a/src/main/java/eu/bitfield/recipes/util/AsyncUtils.java b/src/main/java/eu/bitfield/recipes/util/AsyncUtils.java new file mode 100644 index 0000000..2a3f2fa --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/AsyncUtils.java @@ -0,0 +1,74 @@ +package eu.bitfield.recipes.util; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import static java.util.function.Function.*; + + +public class AsyncUtils { + public static Function supplyFn(Supplier supplier) { + return (T t) -> supplier.get(); + } + + public static UnaryOperator> errIfEmpty(Supplier err) { + return (Mono mono) -> mono.switchIfEmpty(err(err)); + } + + public static UnaryOperator> errIf(Predicate shouldErr, Function err) { + return (Mono mono) -> mono.flatMap(item -> shouldErr.test(item) ? err(err.apply(item)) : some(item)); + } + + public static UnaryOperator> errIfNot(Predicate isOk, Function err) { + return (Mono mono) -> mono.flatMap(item -> isOk.test(item) ? some(item) : err(err.apply(item))); + } + + public static Mono some(T some) {return Mono.just(some);} + + @SafeVarargs + public static Flux many(T... many) {return Flux.just(many);} + + public static Mono none() {return Mono.empty();} + + public static Mono err(Supplier error) {return Mono.error(error);} + + public static Mono err(Throwable error) {return Mono.error(error);} + + public static Function, Mono> errIfTrue(Supplier err) { + return (Mono mono) -> mono.flatMap(ok -> ok ? err(err.get()) : some(false)); + } + + public static Function, Mono> errIfFalse(Supplier errorSupplier) { + return (Mono mono) -> mono.flatMap(ok -> ok ? some(true) : err(errorSupplier.get())); + } + + public static Mono supply(Supplier supplier) {return Mono.fromSupplier(supplier);} + + public static Flux flux(Iterable iterable) {return Flux.fromIterable(iterable);} + + public static Flux flux(Stream stream) {return Flux.fromStream(stream);} + + public static UnaryOperator> chain(Function> visit) { + return (Mono mono) -> mono.flatMap(t1 -> visit.apply(t1).thenReturn(t1)); + } + + public static Mono defer(MonoSupplier supplier) {return Mono.defer(supplier);} + + public static Flux defer(FluxSupplier supplier) {return Flux.defer(supplier);} + + public static UnaryOperator> takeUntilChanged(Function keySelector) { + return (Flux items) -> items.windowUntilChanged(keySelector).take(1).flatMap(identity()); + } + + @FunctionalInterface + public interface MonoSupplier extends Supplier> {} + + @FunctionalInterface + public interface FluxSupplier extends Supplier> {} +} diff --git a/src/main/java/eu/bitfield/recipes/util/Chronology.java b/src/main/java/eu/bitfield/recipes/util/Chronology.java new file mode 100644 index 0000000..b93a628 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/Chronology.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.util; + +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Component +public class Chronology { + public Instant now() {return Instant.now().truncatedTo(ChronoUnit.MICROS);} +} diff --git a/src/main/java/eu/bitfield/recipes/util/CollectionUtils.java b/src/main/java/eu/bitfield/recipes/util/CollectionUtils.java new file mode 100644 index 0000000..bb7546d --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/CollectionUtils.java @@ -0,0 +1,33 @@ +package eu.bitfield.recipes.util; + +import org.springframework.util.MultiValueMap; + +import java.util.*; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collector; + +import static java.util.stream.Collectors.*; + +public class CollectionUtils { + public static MultiValueMap multiValueMap() { + return MultiValueMap.fromMultiValue(new HashMap<>()); + } + + public static MultiValueMap multiValueMap(Map> map) { + return MultiValueMap.fromMultiValue(map); + } + + public static Collector> toHashSet() { + return toCollection(HashSet::new); + } + + public static Collector> toArrayList() { + return toCollection(ArrayList::new); + } + + public static Function, R> entryFn(BiFunction fn) { + return entry -> fn.apply(entry.getKey(), entry.getValue()); + } +} + diff --git a/src/main/java/eu/bitfield/recipes/util/Entity.java b/src/main/java/eu/bitfield/recipes/util/Entity.java new file mode 100644 index 0000000..e390218 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/Entity.java @@ -0,0 +1,8 @@ +package eu.bitfield.recipes.util; + +import org.springframework.data.annotation.Immutable; + +@Immutable +public interface Entity extends Id { + long id(); +} diff --git a/src/main/java/eu/bitfield/recipes/util/ErrorUtils.java b/src/main/java/eu/bitfield/recipes/util/ErrorUtils.java new file mode 100644 index 0000000..ed91976 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/ErrorUtils.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.util; + +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponse; +import org.springframework.web.ErrorResponseException; + +public class ErrorUtils { + public static ErrorResponseException errorResponseException(Throwable ex, HttpStatusCode statusCode) { + ErrorResponse response = errorResponse(ex, statusCode); + return new ErrorResponseException(response.getStatusCode(), response.getBody(), ex); + } + + public static ErrorResponse errorResponse(Throwable ex, HttpStatusCode statusCode) { + StringBuilder messageBuilder = new StringBuilder(ex.getMessage()); + Throwable cause = ex.getCause(); + while (cause != null) { + messageBuilder.append(" > ").append(cause.getMessage()); + cause = cause.getCause(); + } + String message = messageBuilder.toString(); + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(statusCode, message); + return ErrorResponse.builder(ex, problemDetail).build(); + } +} diff --git a/src/main/java/eu/bitfield/recipes/util/Id.java b/src/main/java/eu/bitfield/recipes/util/Id.java new file mode 100644 index 0000000..1f4a5b0 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/Id.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.util; + +public interface Id { + long id(); +} + diff --git a/src/main/java/eu/bitfield/recipes/util/Pagination.java b/src/main/java/eu/bitfield/recipes/util/Pagination.java new file mode 100644 index 0000000..926ff12 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/util/Pagination.java @@ -0,0 +1,3 @@ +package eu.bitfield.recipes.util; + +public record Pagination(long limit, long offset) {} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/RecipeView.java b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeView.java new file mode 100644 index 0000000..6abd41c --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeView.java @@ -0,0 +1,39 @@ +package eu.bitfield.recipes.view.recipe; + +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.category.CategoryOut; +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.ingredient.IngredientOut; +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.recipe.RecipeOut; +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.core.step.StepOut; + +import java.util.List; + +public record RecipeView( + Recipe recipe, + List categories, + List links, + List ingredients, + List steps +) implements ToRecipeViewOut { + + public static RecipeViewOut createRecipeViewOut( + Recipe recipe, + List categories, + List ingredients, + List steps) + { + RecipeOut recipeOut = recipe.toRecipeOut(); + List categoriesOut = categories.stream().map(Category::toCategoryOut).toList(); + List ingredientsOut = ingredients.stream().map(Ingredient::toIngredientOut).toList(); + List stepsOut = steps.stream().map(Step::toStepOut).toList(); + return new RecipeViewOut(recipeOut, categoriesOut, ingredientsOut, stepsOut); + } + + public RecipeViewOut toRecipeViewOut() { + return createRecipeViewOut(recipe, categories, ingredients, steps); + } +} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewIn.java b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewIn.java new file mode 100644 index 0000000..3cff760 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewIn.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.view.recipe; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import eu.bitfield.recipes.core.category.CategoryIn; +import eu.bitfield.recipes.core.ingredient.IngredientIn; +import eu.bitfield.recipes.core.recipe.RecipeIn; +import eu.bitfield.recipes.core.step.StepIn; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.With; + +import java.util.List; + +@With +public record RecipeViewIn( + @JsonUnwrapped @NotNull @Valid RecipeIn recipe, + @NotNull List<@Valid @NotNull CategoryIn> categories, + @NotEmpty List<@Valid @NotNull IngredientIn> ingredients, + @NotEmpty List<@Valid @NotNull StepIn> steps +) implements ToRecipeViewIn { + public RecipeViewIn toRecipeViewIn() { + return this; + } +} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewOut.java b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewOut.java new file mode 100644 index 0000000..4261748 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/RecipeViewOut.java @@ -0,0 +1,23 @@ +package eu.bitfield.recipes.view.recipe; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import eu.bitfield.recipes.core.category.CategoryOut; +import eu.bitfield.recipes.core.ingredient.IngredientOut; +import eu.bitfield.recipes.core.recipe.RecipeOut; +import eu.bitfield.recipes.core.recipe.ToRecipeOut; +import eu.bitfield.recipes.core.step.StepOut; +import lombok.With; + +import java.util.List; + +@With +public record RecipeViewOut( + @JsonUnwrapped RecipeOut recipe, + List categories, + List ingredients, + List steps +) implements ToRecipeOut { + public RecipeOut toRecipeOut() { + return recipe; + } +} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewIn.java b/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewIn.java new file mode 100644 index 0000000..070faba --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewIn.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.view.recipe; + +public interface ToRecipeViewIn { + RecipeViewIn toRecipeViewIn(); +} diff --git a/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewOut.java b/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewOut.java new file mode 100644 index 0000000..a84ac06 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/recipe/ToRecipeViewOut.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.view.recipe; + +public interface ToRecipeViewOut { + RecipeViewOut toRecipeViewOut(); +} diff --git a/src/main/java/eu/bitfield/recipes/view/registration/RegistrationView.java b/src/main/java/eu/bitfield/recipes/view/registration/RegistrationView.java new file mode 100644 index 0000000..f7d3615 --- /dev/null +++ b/src/main/java/eu/bitfield/recipes/view/registration/RegistrationView.java @@ -0,0 +1,9 @@ +package eu.bitfield.recipes.view.registration; + +import eu.bitfield.recipes.core.account.Account; +import eu.bitfield.recipes.core.profile.Profile; + +public record RegistrationView( + Profile profile, + Account account +) {} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..9553b68 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,31 @@ +spring: + application: + name: recipes + r2dbc: + url: "r2dbc:postgresql://localhost:5432/recipes" + username: "recipes" + password: "dev_pw" + main: + banner-mode: "off" + web-application-type: reactive + webflux: + problemdetails: + enabled: true + +# jackson: +# serialization: +# INDENT_OUTPUT: true +#logging: +# level: +# io.r2dbc.pool: debug +# io.r2dbc.postgresql.QUERY: debug +# io.r2dbc.postgresql.PARAM: debug +# org.springframework.r2dbc: debug +# org.springframework.transaction: trace +# org.springframework.r2dbc.connection.init.ScriptUtils: info +# eu.bitfield.recipes.test.api.APICall: debug +# eu.bitfield.recipes.api.ErrorResponseHandling: info +# org.springframework.security: debug +# reactor.netty.http.client: debug +# reactor.netty.http.server: debug +# org.springframework: debug diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..0591894 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,26 @@ + + + + + + + ${CONSOLE_LOG_THRESHOLD} + + + + ^io\.r2dbc\.postgresql\.QUERY$ + Executing query: SHOW TRANSACTION ISOLATION LEVEL + + DENY + NEUTRAL + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..ac51170 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,49 @@ +drop table if exists profile cascade; +create table profile ( + id bigint primary key generated always as identity +); + +drop table if exists account cascade; +create table account ( + id bigint primary key generated always as identity, + profile_id bigint references profile on delete cascade, + email text not null unique, + password text not null +); + +drop table if exists recipe cascade; +create table recipe ( + id bigint primary key generated always as identity, + author_profile_id bigint not null references profile on delete cascade, + name text not null, + description text not null, + changed_at timestamp not null +); + +drop table if exists category cascade; +create table category ( + id bigint primary key generated always as identity, + name text not null unique +); + +drop table if exists link_rec_cat cascade; +create table link_rec_cat ( + id bigint primary key generated always as identity, + recipe_id bigint not null references recipe on delete cascade, + category_id bigint not null references category on delete cascade, + unique (recipe_id, category_id) +); + +drop table if exists ingredient cascade; +create table ingredient ( + id bigint primary key generated always as identity, + recipe_id bigint references recipe on delete cascade, + name text not null +); + +drop table if exists step cascade; +create table step ( + id bigint primary key generated always as identity, + recipe_id bigint references recipe on delete cascade, + name text not null +); diff --git a/src/test/java/eu/bitfield/recipes/api/APITest.java b/src/test/java/eu/bitfield/recipes/api/APITest.java new file mode 100644 index 0000000..d7248eb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/APITest.java @@ -0,0 +1,178 @@ +package eu.bitfield.recipes.api; + +import eu.bitfield.recipes.api.recipe.RecipeEndpoint; +import eu.bitfield.recipes.core.recipe.RecipeOut; +import eu.bitfield.recipes.test.api.APICalls; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.account.AccountQueries; +import eu.bitfield.recipes.test.core.account.AccountSlot; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeTemplate; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries; +import eu.bitfield.recipes.util.Pagination; +import eu.bitfield.recipes.view.recipe.RecipeViewIn; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import io.r2dbc.spi.ConnectionFactory; +import lombok.Getter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.Resource; +import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator; +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.time.Instant; +import java.util.List; + +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static org.assertj.core.api.Assertions.*; + +@Getter @Accessors(fluent = true) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class APITest implements AccountQueries, RecipeViewQueries, APICalls { + static final Pagination pagination = new Pagination(RecipeEndpoint.MAX_LIMIT, 0L); + + @Autowired WebTestClient client; + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers().profileLayer(); + @Delegate AccountLayer accountLayer = layers().accountLayer(); + @Delegate RecipeLayer recipeLayer = layers().recipeLayer(); + @Delegate CategoryLayer categoryLayer = layers().categoryLayer(); + @Delegate LinkRecCatLayer linkLayer = layers().linkRecCatLayer(); + @Delegate IngredientLayer ingredientLayer = layers().ingredientLayer(); + @Delegate StepLayer stepLayer = layers().stepLayer(); + + static void cleanDatabase(ConnectionFactory connectionFactory, Resource cleanSql) { + var resourceDatabasePopulator = new ResourceDatabasePopulator(cleanSql); + resourceDatabasePopulator.addScript(cleanSql); + resourceDatabasePopulator.populate(connectionFactory).block(); + } + + @AfterAll + static void afterAll(@Autowired ConnectionFactory connectionFactory, + @Value("classpath:/clean.sql") Resource cleanSql) + { + cleanDatabase(connectionFactory, cleanSql); + } + + @BeforeEach + void beforeEach(@Autowired ConnectionFactory connectionFactory, + @Value("classpath:/clean.sql") Resource cleanSql) + { + cleanDatabase(connectionFactory, cleanSql); + } + + @Test + void authentificationRequired() { + long recipeId = freeId; + RecipeViewIn viewIn = recipeViewGroup().toRecipeViewIn(); + addRecipeCall(viewIn).anonymous().unauthorized(); + updateRecipeCall(recipeId, viewIn).anonymous().unauthorized(); + removeRecipeCall(recipeId).anonymous().unauthorized(); + } + + @Test + void accountReregistration() { + AccountSlot account = accountSlot().blank(); + + registerAccountCall(account).ok(); + registerAccountCall(account).badRequest(); + } + + @Test + void recipeCrudFlow() { + AccountSlot author = accountSlot(profiles.ada).blank(); + AccountSlot notAuthor = accountSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author.profile()).blank(); + RecipeViewGroup group = recipeViewGroup(recipe); + RecipeViewIn viewIn = group.toRecipeViewIn(); + + String updatedName = viewIn.recipe().name() + " v2"; + RecipeTemplate updatedRecipeTemplate = group.recipe().template().withName(updatedName); + RecipeSlot updatedRecipe = recipeSlot().template(updatedRecipeTemplate).author(author.profile()).blank(); + RecipeViewGroup updatedGroup = recipeViewGroup(updatedRecipe); + RecipeViewIn updatedViewIn = updatedGroup.toRecipeViewIn(); + + save(combinedSlots(group, updatedGroup)); + + registerAccountCall(author).ok(); + registerAccountCall(notAuthor).ok(); + + RecipeViewOut addedViewOut = addRecipeCall(viewIn).as(author).ok(); + assertThat(addedViewOut).isEqualTo(expectedViewOut(addedViewOut, group)); + + long recipeId = addedViewOut.recipe().id(); + + RecipeViewOut gotViewOut = getRecipeCall(recipeId).ok(); + assertThat(addedViewOut).isEqualTo(gotViewOut); + + removeRecipeCall(recipeId).as(notAuthor).forbidden(); + getRecipeCall(recipeId).ok(); + + updateRecipeCall(recipeId, updatedViewIn).as(notAuthor).forbidden(); + gotViewOut = getRecipeCall(recipeId).ok(); + assertThat(addedViewOut).isEqualTo(gotViewOut); + + RecipeViewOut updatedViewOut = updateRecipeCall(recipeId, updatedViewIn).as(author).ok(); + assertThat(updatedViewOut).isEqualTo(expectedViewOut(updatedViewOut, updatedGroup)); + + gotViewOut = getRecipeCall(recipeId).ok(); + assertThat(gotViewOut).isEqualTo(updatedViewOut); + + removeRecipeCall(recipeId).as(author).ok(); + getRecipeCall(recipeId).notFound(); + } + + + @Test + void recipeSearch() { + AccountSlot account = accountSlot().blank(); + registerAccountCall(account).ok(); + + List allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List allViewsOut = allGroups.stream() + .filter(group -> !group.ingredients().isEmpty() && !group.steps().isEmpty()) + .map(group -> addRecipeCall(group.toRecipeViewIn()).as(account).ok()) + .toList(); + allViewsOut = allViewsOut.reversed(); // newest recipe first + + String categoryName = categorySlotWithLinkCount(2).blank().name(); + List viewsOut = allViewsOut.stream() + .filter((RecipeViewOut viewOut) -> viewOut.categories().stream().anyMatch(hasCategoryName(categoryName))) + .toList(); + List actualViewsOut = findRecipesCall().category(categoryName).pagination(pagination).ok(); + assertThat(actualViewsOut).isEqualTo(viewsOut); + + String recipeName = "ma"; + viewsOut = allViewsOut.stream().filter(containsRecipeName(recipeName)).toList(); + actualViewsOut = findRecipesCall().recipe(recipeName).pagination(pagination).ok(); + assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut); + } + + + private RecipeViewOut expectedViewOut(RecipeViewOut actual, RecipeViewGroup group) { + RecipeViewOut assumed = group.toRecipeViewOut(); + long actualRecipeId = actual.recipe().id(); + Instant actualChangedAt = actual.recipe().changedAt(); + RecipeOut expectedRecipe = assumed.recipe().withId(actualRecipeId).withChangedAt(actualChangedAt); + return assumed.withRecipe(expectedRecipe); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/account/AccountEndpointTest.java b/src/test/java/eu/bitfield/recipes/api/account/AccountEndpointTest.java new file mode 100644 index 0000000..419984d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/account/AccountEndpointTest.java @@ -0,0 +1,83 @@ +package eu.bitfield.recipes.api.account; + + +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.auth.password.PasswordIn; +import eu.bitfield.recipes.core.account.AccountIn; +import eu.bitfield.recipes.core.account.AccountService; +import eu.bitfield.recipes.test.api.APICalls; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.account.AccountQueries; +import eu.bitfield.recipes.test.core.account.AccountSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.view.registration.RegistrationView; +import lombok.Getter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.TestUtils.*; +import static org.mockito.Mockito.*; + +@Getter @Accessors(fluent = true) +@WebFluxTest(value = AccountEndpoint.class, excludeAutoConfiguration = { + ReactiveUserDetailsServiceAutoConfiguration.class, + ReactiveSecurityAutoConfiguration.class +}) +public class AccountEndpointTest implements AccountQueries, APICalls { + @MockitoBean RegisterAccount registerAccount; + @Autowired WebTestClient client; + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate AccountLayer accountLayer = layers.accountLayer(); + + @Test + void registerAccount_accountInEmailOk_ok() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + AccountIn accountIn = account.toAccountIn(); + var registrationView = new RegistrationView(account.profile().saved(), account.saved()); + + when(registerAccount.registerAccount(accountIn)).thenReturn(some(registrationView)); + + registerAccountCall(accountIn).ok(); + } + + @Test + void registerAccount_accountInEmailInUse_badRequest() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + AccountIn accountIn = account.toAccountIn(); + + var err = new RegisterAccount.EmailAlreadyInUse(new AccountService.EmailAlreadyInUse(account.email())); + + when(registerAccount.registerAccount(accountIn)).thenReturn(err(err)); + + registerAccountCall(accountIn).badRequest(); + } + + @Test + void registerAccount_invalidAccountIn_badRequest() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + AccountIn accountIn = account.toAccountIn(); + EmailAddressIn invalidEmail = EmailAddressIn.ofUnchecked(accountIn.email().address() + "@@"); + PasswordIn invalidPassword = PasswordIn.ofUnchecked("shortpw"); + accountIn = accountIn.withEmail(invalidEmail).withPassword(invalidPassword); + + when(registerAccount.registerAccount(any())).thenReturn(noSubscriptionMono()); + + registerAccountCall(accountIn).badRequest(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/account/RegisterAccountTest.java b/src/test/java/eu/bitfield/recipes/api/account/RegisterAccountTest.java new file mode 100644 index 0000000..d690951 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/account/RegisterAccountTest.java @@ -0,0 +1,70 @@ +package eu.bitfield.recipes.api.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.auth.password.Password; +import eu.bitfield.recipes.core.account.AccountIn; +import eu.bitfield.recipes.core.account.AccountService; +import eu.bitfield.recipes.core.profile.ProfileService; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.account.AccountQueries; +import eu.bitfield.recipes.test.core.account.AccountSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.view.registration.RegistrationView; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.TestUtils.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class RegisterAccountTest implements AccountQueries { + ProfileService profileServ = mock(ProfileService.class); + AccountService accountServ = mock(AccountService.class); + RegisterAccount registerAccount = new RegisterAccount(profileServ, accountServ); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate AccountLayer accountLayer = layers.accountLayer(); + + @Test + void register_accountInWithUnusedEmail_accountRegistrationRegisterAccount() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + AccountIn accountIn = account.toAccountIn(); + EmailAddress email = account.email(); + Password password = account.password(); + var registrationView = new RegistrationView(account.profile().saved(), account.saved()); + + when(accountServ.checkEmail(email)).thenReturn(some(email)); + when(profileServ.addProfile()).thenReturn(some(account.profile().saved())); + when(accountServ.encode(account.passwordIn())).thenReturn(password); + when(accountServ.addAccount(account.profile().id(), email, password)).thenReturn(some(account.saved())); + + registerAccount.registerAccount(accountIn) + .as(StepVerifier::create) + .expectNext(registrationView) + .verifyComplete(); + } + + @Test + void registerAccount_accountInWithUsedEmail_errorEmailAlreadyInUse() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + AccountIn accountIn = account.toAccountIn(); + EmailAddress email = account.email(); + + when(accountServ.checkEmail(email)).thenReturn(err(new AccountService.EmailAlreadyInUse(email))); + when(profileServ.addProfile()).thenReturn(noSubscriptionMono()); + when(accountServ.addAccount(anyLong(), any(), any())).thenReturn(noSubscriptionMono()); + + registerAccount.registerAccount(accountIn) + .as(StepVerifier::create) + .verifyError(RegisterAccount.EmailAlreadyInUse.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/AddRecipeTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/AddRecipeTest.java new file mode 100644 index 0000000..17bc8e5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/AddRecipeTest.java @@ -0,0 +1,71 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.category.CategoryService; +import eu.bitfield.recipes.core.ingredient.IngredientService; +import eu.bitfield.recipes.core.link.LinkRecCatService; +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.core.step.StepService; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries; +import eu.bitfield.recipes.util.Chronology; +import eu.bitfield.recipes.view.recipe.RecipeView; +import eu.bitfield.recipes.view.recipe.RecipeViewIn; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.time.Instant; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static org.mockito.Mockito.*; + + +public class AddRecipeTest implements RecipeViewQueries { + RecipeService recipeServ = mock(RecipeService.class); + CategoryService categoryServ = mock(CategoryService.class); + LinkRecCatService linkServ = mock(LinkRecCatService.class); + IngredientService ingredientServ = mock(IngredientService.class); + StepService stepServ = mock(StepService.class); + Chronology time = mock(Chronology.class); + AddRecipe addRecipe = new AddRecipe(recipeServ, categoryServ, linkServ, ingredientServ, stepServ, time); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + @Delegate IngredientLayer ingredientLayer = layers.ingredientLayer(); + @Delegate StepLayer stepLayer = layers.stepLayer(); + + @Test + void addRecipe_anyRecipeViewIn_recipeViewAddRecipe() { + RecipeViewGroup group = recipeViewGroup(); + save(slots(group)); + RecipeViewIn viewIn = group.toRecipeViewIn(); + RecipeView view = group.toRecipeView(); + RecipeSlot recipe = group.recipe(); + Instant createdAt = recipe.saved().changedAt(); + + when(time.now()).thenReturn(createdAt); + when(recipeServ.addRecipe(recipe.author().id(), viewIn.recipe(), createdAt)).thenReturn(some(recipe.saved())); + when(categoryServ.addCategories(viewIn.categories())).thenReturn(some(view.categories())); + when(linkServ.addLinks(recipe.id(), view.categories())).thenReturn(some(view.links())); + when(ingredientServ.addIngredients(recipe.id(), viewIn.ingredients())).thenReturn(some(view.ingredients())); + when(stepServ.addSteps(recipe.id(), viewIn.steps())).thenReturn(some(view.steps())); + + addRecipe.addRecipe(viewIn, recipe.author().id()) + .as(StepVerifier::create) + .expectNext(view) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/FindRecipesTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/FindRecipesTest.java new file mode 100644 index 0000000..230bbcf --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/FindRecipesTest.java @@ -0,0 +1,94 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries; +import eu.bitfield.recipes.util.Pagination; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.List; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class FindRecipesTest implements RecipeViewQueries { + RecipeService recipeServ = mock(RecipeService.class); + GetRecipe getRecipe = mock(GetRecipe.class); + FindRecipes findRecipes = new FindRecipes(recipeServ, getRecipe); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + @Delegate IngredientLayer ingredientLayer = layers.ingredientLayer(); + @Delegate StepLayer stepLayer = layers.stepLayer(); + + @Test + void findRecipes_categoryName_recipeViewsOut() { + String categoryName = categorySlotWithLinkCount(2).blank().name(); + List allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List groups = allGroups.stream() + .filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName))) + .sorted(orderByRecipeChangedAtDesc()) + .toList(); + List savedViewsOut = groups.stream().map(toRecipeViewOut).toList(); + List recipeIds = groups.stream().map(toRecipe).map(toId).toList(); + long recipeCount = recipeIds.size(); + var pagination = new Pagination(recipeCount, 0); + String recipeName = null; + + for (var group : allGroups) { + when(getRecipe.getRecipe(group.recipe().id())).thenReturn(some(group.toRecipeViewOut())); + } + when(recipeServ.findRecipeIds(categoryName, recipeName, pagination)).thenReturn(flux(recipeIds)); + + findRecipes.findRecipes(categoryName, recipeName, pagination) + .as(StepVerifier::create) + .assertNext((List actualViewsOut) -> { + assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut); + }); + } + + @Test + void findRecipes_recipeNamePart_recipeViewsOut() { + String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra + List allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List groups = allGroups.stream() + .filter(containsRecipeName(recipeName)) + .sorted(orderByRecipeChangedAtDesc()) + .toList(); + List savedViewsOut = groups.stream().map(toRecipeViewOut).toList(); + List recipeIds = groups.stream().map(toRecipe).map(toId).toList(); + long recipeCount = recipeIds.size(); + var pagination = new Pagination(recipeCount, 0); + String categoryName = null; + + for (var group : allGroups) { + when(getRecipe.getRecipe(group.recipe().id())).thenReturn(some(group.toRecipeViewOut())); + } + when(recipeServ.findRecipeIds(categoryName, recipeName, pagination)).thenReturn(flux(recipeIds)); + + findRecipes.findRecipes(categoryName, recipeName, pagination) + .as(StepVerifier::create) + .assertNext((List actualViewsOut) -> { + assertThat(actualViewsOut).containsExactlyElementsOf(savedViewsOut); + }); + } +} \ No newline at end of file diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/GetRecipeTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/GetRecipeTest.java new file mode 100644 index 0000000..38fb4e4 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/GetRecipeTest.java @@ -0,0 +1,73 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.category.CategoryService; +import eu.bitfield.recipes.core.ingredient.IngredientService; +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.core.step.StepService; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries; +import eu.bitfield.recipes.view.recipe.RecipeView; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static org.mockito.Mockito.*; + +public class GetRecipeTest implements RecipeViewQueries { + RecipeService recipeServ = mock(RecipeService.class); + CategoryService categoryServ = mock(CategoryService.class); + IngredientService ingredientServ = mock(IngredientService.class); + StepService stepServ = mock(StepService.class); + GetRecipe getRecipe = new GetRecipe(recipeServ, categoryServ, ingredientServ, stepServ); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + @Delegate IngredientLayer ingredientLayer = layers.ingredientLayer(); + @Delegate StepLayer stepLayer = layers.stepLayer(); + + @Test + void getRecipe_nonExistentRecipeId_errorNotFound() { + long recipeId = freeId; + + when(recipeServ.getRecipe(recipeId)).thenReturn(err(new RecipeService.RecipeNotFound(freeId))); + + getRecipe.getRecipe(recipeId) + .as(StepVerifier::create) + .verifyError(GetRecipe.NotFound.class); + } + + @Test + void getRecipe_existentRecipeId_recipeViewOutGetRecipe() { + RecipeViewGroup group = recipeViewGroup(); + save(slots(group)); + RecipeSlot recipe = group.recipe(); + RecipeView view = group.toRecipeView(); + RecipeViewOut viewOut = group.toRecipeViewOut(); + + when(recipeServ.getRecipe(recipe.id())).thenReturn(some(view.recipe())); + when(categoryServ.getCategories(recipe.id())).thenReturn(some(view.categories())); + when(ingredientServ.getIngredients(recipe.id())).thenReturn(some(view.ingredients())); + when(stepServ.getSteps(recipe.id())).thenReturn(some(view.steps())); + + getRecipe.getRecipe(recipe.id()) + .as(StepVerifier::create) + .expectNext(viewOut) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/RecipeEndpointTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/RecipeEndpointTest.java new file mode 100644 index 0000000..655f0ec --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/RecipeEndpointTest.java @@ -0,0 +1,276 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.auth.ProfileIdentityAccess; +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.test.InvalidEntity; +import eu.bitfield.recipes.test.api.APICalls; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.account.AccountQueries; +import eu.bitfield.recipes.test.core.account.AccountSlot; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries; +import eu.bitfield.recipes.util.Pagination; +import eu.bitfield.recipes.view.recipe.RecipeView; +import eu.bitfield.recipes.view.recipe.RecipeViewIn; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.Getter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.util.List; + +import static eu.bitfield.recipes.api.recipe.RecipeEndpoint.*; +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.TestUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@WebFluxTest(value = RecipeEndpoint.class, excludeAutoConfiguration = { + ReactiveUserDetailsServiceAutoConfiguration.class, + ReactiveSecurityAutoConfiguration.class +}) +@Getter @Accessors(fluent = true) +public class RecipeEndpointTest implements RecipeViewQueries, AccountQueries, APICalls { + static final Pagination pagination = new Pagination(RecipeEndpoint.MAX_LIMIT, 0L); + static final long freeRecipeId = InvalidEntity.freeId; + + @MockitoBean AddRecipe addRecipe; + @MockitoBean GetRecipe getRecipe; + @MockitoBean UpdateRecipe updateRecipe; + @MockitoBean RemoveRecipe removeRecipe; + @MockitoBean FindRecipes findRecipes; + @MockitoBean ProfileIdentityAccess profileIdentity; + + @Autowired WebTestClient client; + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate AccountLayer accountLayer = layers.accountLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + @Delegate IngredientLayer ingredientLayer = layers.ingredientLayer(); + @Delegate StepLayer stepLayer = layers.stepLayer(); + + @Test + void addRecipe_validRecipeIn_okRecipeId() { + RecipeViewGroup group = recipeViewGroup(); + AccountSlot author = accountSlot().profile(group.recipe().author()).blank(); + save(slots(group).add(author)); + RecipeViewIn viewIn = group.toRecipeViewIn(); + RecipeView view = group.toRecipeView(); + RecipeViewOut viewOut = group.toRecipeViewOut(); + + when(profileIdentity.id()).thenReturn(some(author.profile().id())); + when(addRecipe.addRecipe(viewIn, author.profile().id())).thenReturn(some(view)); + + RecipeViewOut actualViewOut = addRecipeCall(viewIn).as(author).ok(); + assertThat(actualViewOut).isEqualTo(viewOut); + } + + @Test + void addRecipe_invalidRecipeIn_badRequest() { + RecipeViewGroup group = recipeViewGroup(); + AccountSlot author = accountSlot().profile(group.recipe().author()).blank(); + save(slots(group).add(author)); + RecipeViewIn viewIn = invalid(group.toRecipeViewIn()); + + when(profileIdentity.id()).thenReturn(noSubscriptionMono()); + when(addRecipe.addRecipe(any(), anyLong())).thenReturn(noSubscriptionMono()); + + addRecipeCall(viewIn).as(author).badRequest(); + } + + @Test + void getRecipe_existentRecipeId_okRecipeViewOut() { + RecipeViewGroup group = recipeViewGroup(); + save(slots(group)); + RecipeViewOut viewOut = group.toRecipeViewOut(); + long recipeId = group.recipe().id(); + + when(getRecipe.getRecipe(recipeId)).thenReturn(some(viewOut)); + + RecipeViewOut actualViewOut = getRecipeCall(recipeId).ok(); + assertThat(actualViewOut).isEqualTo(viewOut); + } + + @Test + void getRecipe_nonExistentRecipeId_notFound() { + var err = new GetRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId)); + + when(getRecipe.getRecipe(freeRecipeId)).thenReturn(err(err)); + + getRecipeCall(freeRecipeId).notFound(); + } + + @Test + void updateRecipe_existentRecipeId$validRecipeViewIn$author_ok() { + RecipeViewGroup group = recipeViewGroup(); + AccountSlot author = accountSlot().profile(group.recipe().author()).blank(); + save(slots(group).add(author)); + RecipeSlot recipe = group.recipe(); + long profileId = recipe.author().id(); + RecipeViewIn viewIn = group.toRecipeViewIn(); + RecipeView view = group.toRecipeView(); + RecipeViewOut viewOut = group.toRecipeViewOut(); + + when(profileIdentity.id()).thenReturn(some(profileId)); + when(updateRecipe.updateRecipe(recipe.id(), viewIn, profileId)).thenReturn(some(view)); + + RecipeViewOut actualViewOut = updateRecipeCall(recipe.id(), viewIn).as(author).ok(); + assertThat(actualViewOut).isEqualTo(viewOut); + } + + @Test + void updateRecipe_nonExistentRecipeId$validRecipeViewIn_notFound() { + RecipeViewGroup group = recipeViewGroup(); + AccountSlot account = accountSlot().profile(group.recipe().author()).blank(); + save(slots(group).add(account)); + long profileId = group.recipe().author().id(); + RecipeViewIn viewIn = group.toRecipeViewIn(); + var err = new UpdateRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId)); + + when(profileIdentity.id()).thenReturn(some(profileId)); + when(updateRecipe.updateRecipe(freeRecipeId, viewIn, profileId)).thenReturn(err(err)); + + updateRecipeCall(freeRecipeId, viewIn).as(account).notFound(); + } + + @Test + void updateRecipe_existentRecipeId$ValidRecipeViewIn$notAuthor_forbidden() { + AccountSlot author = accountSlot(profiles.ada).blank(); + AccountSlot notAuthor = accountSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author.profile()).blank(); + RecipeViewGroup group = recipeViewGroup(recipe); + save(slots(group).add(author, notAuthor)); + RecipeViewIn viewIn = group.toRecipeViewIn(); + long recipeId = group.recipe().id(); + var err = new UpdateRecipe.Forbidden(new RecipeService.UpdateRecipeForbidden(recipeId)); + + when(profileIdentity.id()).thenReturn(some(notAuthor.profile().id())); + when(updateRecipe.updateRecipe(recipeId, viewIn, notAuthor.profile().id())).thenReturn(err(err)); + + updateRecipeCall(recipeId, viewIn).as(notAuthor).forbidden(); + } + + @Test + void updateRecipe_invalidRecipeViewIn_badRequest() { + RecipeViewGroup group = recipeViewGroup(); + AccountSlot account = accountSlot().profile(group.recipe().author()).blank(); + RecipeViewIn viewIn = invalid(group.toRecipeViewIn()); + + when(profileIdentity.id()).thenReturn(noSubscriptionMono()); + when(updateRecipe.updateRecipe(anyLong(), any(), anyLong())).thenReturn(noSubscriptionMono()); + + updateRecipeCall(freeRecipeId, viewIn).as(account).badRequest(); + } + + @Test + void removeRecipe_existentRecipeId$author_ok() { + AccountSlot author = accountSlot().blank(); + RecipeSlot recipe = recipeSlot().author(author.profile()).blank(); + save(slot(author).add(recipe)); + + when(profileIdentity.id()).thenReturn(some(author.profile().id())); + when(removeRecipe.removeRecipe(recipe.id(), author.profile().id())).thenReturn(some(true)); + + removeRecipeCall(recipe.id()).as(author).ok(); + } + + @Test + void removeRecipe_nonExistentRecipeId_notFound() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + var err = new RemoveRecipe.NotFound(new RecipeService.RecipeNotFound(freeRecipeId)); + + when(profileIdentity.id()).thenReturn(some(account.profile().id())); + when(removeRecipe.removeRecipe(freeRecipeId, account.profile().id())).thenReturn(err(err)); + + removeRecipeCall(freeRecipeId).as(account).notFound(); + } + + @Test + void removeRecipe_existentRecipeId$notAuthor_forbidden() { + AccountSlot author = accountSlot(profiles.ada).blank(); + AccountSlot notAuthor = accountSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author.profile()).blank(); + save(slot(recipe).add(author, notAuthor)); + var err = new RemoveRecipe.Forbidden(new RecipeService.RemoveRecipeForbidden(recipe.id())); + + when(profileIdentity.id()).thenReturn(some(notAuthor.profile().id())); + when(removeRecipe.removeRecipe(recipe.id(), notAuthor.profile().id())).thenReturn(err(err)); + + removeRecipeCall(recipe.id()).as(notAuthor).forbidden(); + } + + @Test + void findRecipes_someCategoryName$noRecipeName_okAllRecipeViewOut() { + String categoryName = categorySlotWithLinkCount(2).blank().name(); + String recipeName = null; + List allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List groups = allGroups.stream() + .filter((RecipeViewGroup group) -> group.categories().stream().anyMatch(hasCategoryName(categoryName))) + .sorted(orderByRecipeChangedAtDesc()) + .toList(); + List viewsOut = groups.stream().map(toRecipeViewOut).toList(); + + when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut)); + + List actualViewsOut = findRecipesCall().category(categoryName).pagination(pagination).ok(); + assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut); + } + + @Test + void findRecipes_noCategoryName$someRecipeName_okAllRecipeViewOut() { + String categoryName = null; + String recipeName = "ma"; // To[ma]to salad, Muham[ma]ra + List allGroups = recipeViewGroups().toList(); + save(combinedSlots(allGroups)); + List groups = allGroups.stream() + .filter(containsRecipeName(recipeName)) + .sorted(orderByRecipeChangedAtDesc()) + .toList(); + List viewsOut = groups.stream().map(RecipeViewGroup::toRecipeViewOut).toList(); + + when(findRecipes.findRecipes(categoryName, recipeName, pagination)).thenReturn(some(viewsOut)); + + List actualViewsOut = findRecipesCall().recipe(recipeName).pagination(pagination).ok(); + assertThat(actualViewsOut).containsExactlyElementsOf(viewsOut); + } + + @Test + void findRecipes_invalidLimit_badRequest() { + when(findRecipes.findRecipes(any(), any(), any())).thenReturn(noSubscriptionMono()); + + for (long limit : List.of(MIN_LIMIT - 1, MAX_LIMIT + 1)) { + findRecipesCall().recipe("any").limit(limit).offset(0L).badRequest(); + } + } + + @Test + void findRecipes_invalidOffset_badRequestProblemDetail() { + when(findRecipes.findRecipes(any(), any(), any())).thenReturn(noSubscriptionMono()); + + findRecipesCall().recipe("any").limit(MAX_LIMIT).offset(-1L).badRequest(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/RemoveRecipeTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/RemoveRecipeTest.java new file mode 100644 index 0000000..9571840 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/RemoveRecipeTest.java @@ -0,0 +1,70 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeQueries; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static org.mockito.Mockito.*; + +public class RemoveRecipeTest implements RecipeQueries { + RecipeService serv = mock(RecipeService.class); + RemoveRecipe removeRecipe = new RemoveRecipe(serv); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + + @Test + void removeRecipe_nonExistentRecipeId_errorNotFound() { + long recipeId = freeId; + ProfileSlot profile = profileSlot().blank(); + save(slot(profile)); + + when(serv.removeRecipe(recipeId, profile.id())).thenReturn(err(new RecipeService.RecipeNotFound(recipeId))); + + removeRecipe.removeRecipe(recipeId, profile.id()) + .as(StepVerifier::create) + .verifyError(RemoveRecipe.NotFound.class); + } + + @Test + void removeRecipe_notAuthorProfileId_errorForbidden() { + ProfileSlot author = profileSlot(profiles.ada).blank(); + ProfileSlot notAuthor = profileSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author).blank(); + save(slots(author, notAuthor).add(recipe)); + + when(serv.removeRecipe(recipe.id(), notAuthor.id())).thenReturn(err(new RemoveRecipeForbidden(recipe.id()))); + + removeRecipe.removeRecipe(recipe.id(), notAuthor.id()) + .as(StepVerifier::create) + .verifyError(RemoveRecipe.Forbidden.class); + } + + @Test + void removeRecipe_anyRecipeIn_removedRecipe() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + + when(serv.removeRecipe(recipe.id(), recipe.author().id())).thenReturn(some(true)); + + removeRecipe.removeRecipe(recipe.id(), recipe.author().id()) + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/api/recipe/UpdateRecipeTest.java b/src/test/java/eu/bitfield/recipes/api/recipe/UpdateRecipeTest.java new file mode 100644 index 0000000..8ada779 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/api/recipe/UpdateRecipeTest.java @@ -0,0 +1,131 @@ +package eu.bitfield.recipes.api.recipe; + +import eu.bitfield.recipes.core.category.CategoryService; +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.ingredient.IngredientService; +import eu.bitfield.recipes.core.link.LinkRecCatService; +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.recipe.RecipeIn; +import eu.bitfield.recipes.core.recipe.RecipeService; +import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound; +import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden; +import eu.bitfield.recipes.core.step.StepService; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.test.view.recipe.RecipeViewQueries; +import eu.bitfield.recipes.util.Chronology; +import eu.bitfield.recipes.view.recipe.RecipeView; +import eu.bitfield.recipes.view.recipe.RecipeViewIn; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; + +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.TestUtils.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class UpdateRecipeTest implements RecipeViewQueries { + RecipeService recipeServ = mock(RecipeService.class); + CategoryService categoryServ = mock(CategoryService.class); + LinkRecCatService linkServ = mock(LinkRecCatService.class); + StepService stepServ = mock(StepService.class); + IngredientService ingredientServ = mock(IngredientService.class); + Chronology time = mock(Chronology.class); + UpdateRecipe updateRecipe = new UpdateRecipe(recipeServ, categoryServ, linkServ, stepServ, ingredientServ, time); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + @Delegate IngredientLayer ingredientLayer = layers.ingredientLayer(); + @Delegate StepLayer stepLayer = layers.stepLayer(); + + @Test + void updateRecipe_nonExistentRecipeId_errorNotFound() { + RecipeViewGroup group = recipeViewGroup(); + save(slots(group)); + long recipeId = freeId; + long profileId = group.recipe().author().id(); + RecipeViewIn viewIn = group.toRecipeViewIn(); + Instant changedAt = realTime().now(); + var err = new RecipeNotFound(recipeId); + + when(time.now()).thenReturn(changedAt); + when(recipeServ.updateRecipe(recipeId, profileId, viewIn.recipe(), changedAt)).thenReturn(err(err)); + when(categoryServ.addCategories(any())).thenReturn(noSubscriptionMono()); + when(linkServ.updateLinks(anyLong(), any())).thenReturn(noSubscriptionMono()); + when(stepServ.updateSteps(anyLong(), any())).thenReturn(noSubscriptionMono()); + when(ingredientServ.updateIngredients(anyLong(), any())).thenReturn(noSubscriptionMono()); + + updateRecipe.updateRecipe(recipeId, viewIn, profileId) + .as(StepVerifier::create) + .verifyError(UpdateRecipe.NotFound.class); + } + + @Test + void updateRecipe_notAuthorProfileId_errorForbidden() { + ProfileSlot author = profileSlot(profiles.ada).blank(); + ProfileSlot notAuthor = profileSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author).blank(); + RecipeViewGroup group = recipeViewGroup(recipe); + save(slots(group).add(author, notAuthor)); + RecipeViewIn viewIn = group.toRecipeViewIn(); + RecipeIn recipeIn = viewIn.recipe(); + Instant changedAt = realTime().now(); + var err = new UpdateRecipeForbidden(recipe.id()); + + when(time.now()).thenReturn(changedAt); + when(recipeServ.updateRecipe(recipe.id(), notAuthor.id(), recipeIn, changedAt)).thenReturn(err(err)); + when(categoryServ.addCategories(any())).thenReturn(noSubscriptionMono()); + when(linkServ.updateLinks(anyLong(), any())).thenReturn(noSubscriptionMono()); + when(stepServ.updateSteps(anyLong(), any())).thenReturn(noSubscriptionMono()); + when(ingredientServ.updateIngredients(anyLong(), any())).thenReturn(noSubscriptionMono()); + + updateRecipe.updateRecipe(recipe.id(), viewIn, notAuthor.id()) + .as(StepVerifier::create) + .verifyError(UpdateRecipe.Forbidden.class); + + } + + @Test + void updateRecipe_anyRecipeIn_recipeView() { + RecipeViewGroup group = recipeViewGroup(); + save(slots(group)); + RecipeView view = group.toRecipeView(); + RecipeViewIn viewIn = group.toRecipeViewIn(); + Recipe recipe = view.recipe(); + List ingredients = view.ingredients(); + long recipeId = recipe.id(); + long profileId = recipe.authorProfileId(); + Instant changedAt = recipe.changedAt(); + + when(time.now()).thenReturn(changedAt); + when(recipeServ.updateRecipe(recipeId, profileId, viewIn.recipe(), changedAt)).thenReturn(some(recipe)); + when(categoryServ.addCategories(viewIn.categories())).thenReturn(some(view.categories())); + when(linkServ.updateLinks(recipeId, view.categories())).thenReturn(some(view.links())); + when(stepServ.updateSteps(recipeId, viewIn.steps())).thenReturn(some(view.steps())); + when(ingredientServ.updateIngredients(recipeId, viewIn.ingredients())).thenReturn(some(ingredients)); + + updateRecipe.updateRecipe(recipeId, viewIn, profileId) + .as(StepVerifier::create) + .expectNext(view) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/auth/AccountPrincipalServiceTest.java b/src/test/java/eu/bitfield/recipes/auth/AccountPrincipalServiceTest.java new file mode 100644 index 0000000..6b5aa3c --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/auth/AccountPrincipalServiceTest.java @@ -0,0 +1,53 @@ +package eu.bitfield.recipes.auth; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.core.account.AccountService; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.account.AccountQueries; +import eu.bitfield.recipes.test.core.account.AccountSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static org.mockito.Mockito.*; + +public class AccountPrincipalServiceTest implements AccountQueries { + AccountService accountServ = mock(AccountService.class); + AccountPrincipalService serv = new AccountPrincipalService(accountServ); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate AccountLayer accountLayer = layers.accountLayer(); + + @Test + void findByUserName_existentAccountEmail_accountPrincipal() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + String address = account.emailIn().address().toUpperCase(); + var principal = new AccountPrincipal(account.saved()); + + when(accountServ.getAccount(account.email())).thenReturn(some(account.saved())); + + serv.findByUsername(address) + .as(StepVerifier::create) + .expectNext(principal) + .verifyComplete(); + } + + @Test + void findByUserName_nonExistentAccountEmail_none() { + EmailAddress email = unusedEmail; + + when(accountServ.getAccount(email)).thenReturn(none()); + + serv.findByUsername(email.address()) + .as(StepVerifier::create) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/account/AccountRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/account/AccountRepositoryTest.java new file mode 100644 index 0000000..728afa4 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/account/AccountRepositoryTest.java @@ -0,0 +1,140 @@ +package eu.bitfield.recipes.core.account; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.profile.ProfileRepository; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.account.AccountQueries; +import eu.bitfield.recipes.test.core.account.AccountSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.data.AsyncLayerFactory; +import eu.bitfield.recipes.test.data.AsyncRootStorage; +import eu.bitfield.recipes.util.Transaction; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.reactive.TransactionalOperator; + +import java.util.List; + +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +class AccountRepositoryTest implements AccountQueries { + + final AccountRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final @Delegate AccountLayer accountLayer; + final Transaction transaction; + + @Autowired + public AccountRepositoryTest( + ProfileRepository profileRepo, + AccountRepository accountRepo, + TransactionalOperator transactionalOperator) + { + this.repo = accountRepo; + this.rootStorage = new AsyncRootStorage(); + var layers = new AsyncLayerFactory(rootStorage); + this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo)); + this.accountLayer = layers.accountLayer(asyncPersistence(accountRepo)); + this.transaction = new Transaction(transactionalOperator); + } + + @Test + void addAccount_validInitialAccount_savedAccount() { + AccountSlot account = accountSlot().blank(); + @NoArgsConstructor @AllArgsConstructor @Setter @Accessors(fluent = true) + class Check { + Account initial, actual, saved; + } + + var checks = supply(() -> new Check().initial(account.initial())) + .zipWhen((Check c) -> repo.addAccount(c.initial), Check::actual) + .zipWhen((Check c) -> repo.findById(c.actual.id()), Check::saved); + + init(slot(account)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((Check c) -> { + assertThat(c.actual.id()).isNotZero(); + assertThat(c.actual).usingRecursiveComparison().ignoringFields(Account.Fields.id).isEqualTo(c.initial); + assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved); + }) + .verifyComplete(); + } + + @Test + void addAccount_invalidAccountNonExistentProfileId_errorForeignKeyConstraintViolation() { + AccountSlot account = accountSlot().blank(); + + var checks = defer(() -> repo.addAccount(account.initial())); + + invalidate(account.profile()); + init(slot(account)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void accountByEmail_usedAccountEmail_account() { + List accounts = accountSlots().limit(2).map(to::blank).toList(); + AccountSlot account = accounts.getFirst(); + + var checks = defer(() -> repo.accountByEmail(account.email())) + .zipWhen((Account actual) -> some(account.saved())); + + save(slots(accounts)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Account actual, Account saved) -> { + assertThat(actual).usingRecursiveComparison().isEqualTo(saved); + })) + .verifyComplete(); + + } + + @Test + void accountByEmail_unusedEmail_none() { + var checks = repo.accountByEmail(unusedEmail); + + checks.as(transaction::rollbackVerify) + .verifyComplete(); + } + + @Test + void isEmailUsed_usedAccountEmail_true() { + AccountSlot account = accountSlot().blank(); + + var checks = defer(() -> repo.isEmailUsed(account.email())); + + save(slot(account)) + .then(checks) + .as(transaction::rollbackVerify) + .expectNext(true) + .verifyComplete(); + } + + @Test + void isEmailUsed_unusedAccountEmail_false() { + var checks = repo.isEmailUsed(unusedEmail); + + checks.as(transaction::rollbackVerify) + .expectNext(false) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/account/AccountServiceTest.java b/src/test/java/eu/bitfield/recipes/core/account/AccountServiceTest.java new file mode 100644 index 0000000..bff2405 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/account/AccountServiceTest.java @@ -0,0 +1,80 @@ +package eu.bitfield.recipes.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.account.AccountQueries; +import eu.bitfield.recipes.test.core.account.AccountSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.password.PasswordEncoder; +import reactor.test.StepVerifier; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static org.mockito.Mockito.*; + +public class AccountServiceTest implements AccountQueries { + AccountRepository repo = mock(AccountRepository.class); + PasswordEncoder encoder = mock(PasswordEncoder.class); + AccountService serv = new AccountService(repo, encoder); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate AccountLayer accountLayer = layers.accountLayer(); + + @Test + public void addAccount_existentProfileId_savedAccount() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + + when(encoder.encode(account.passwordIn().raw())).thenReturn(account.password().encoded()); + when(repo.addAccount(account.initial())).thenReturn(some(account.saved())); + + serv.addAccount(account.profile().id(), account.email(), account.password()) + .as(StepVerifier::create) + .expectNext(account.saved()) + .verifyComplete(); + } + + + @Test + public void getAccount_existentAccountEmail_account() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + + when(repo.accountByEmail(account.email())).thenReturn(some(account.saved())); + + serv.getAccount(account.email()) + .as(StepVerifier::create) + .expectNext(account.saved()).verifyComplete(); + } + + @Test + public void checkEmailAvailable_usedEmail_errorEmailAlreadyInUse() { + AccountSlot account = accountSlot().blank(); + save(slot(account)); + + when(repo.isEmailUsed(account.email())).thenReturn(some(true)); + + serv.checkEmail(account.email()) + .as(StepVerifier::create) + .verifyError(AccountService.EmailAlreadyInUse.class); + } + + @Test + public void checkEmailAvailable_unusedEmail_none() { + EmailAddress email = unusedEmail; + + when(repo.isEmailUsed(email)).thenReturn(some(false)); + + serv.checkEmail(email) + .as(StepVerifier::create) + .expectNext(email) + .verifyComplete(); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/core/category/CategoryRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/category/CategoryRepositoryTest.java new file mode 100644 index 0000000..354082a --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/category/CategoryRepositoryTest.java @@ -0,0 +1,138 @@ +package eu.bitfield.recipes.core.category; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.link.LinkRecCatRepository; +import eu.bitfield.recipes.core.profile.ProfileRepository; +import eu.bitfield.recipes.core.recipe.RecipeRepository; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatQueries; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AsyncLayerFactory; +import eu.bitfield.recipes.test.data.AsyncRootStorage; +import eu.bitfield.recipes.util.Transaction; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.reactive.TransactionalOperator; + +import java.util.List; + +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +class CategoryRepositoryTest implements LinkRecCatQueries { + final CategoryRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final @Delegate RecipeLayer recipeLayer; + final @Delegate CategoryLayer categoryLayer; + final @Delegate LinkRecCatLayer linkLayer; + final Transaction transaction; + + @Autowired + public CategoryRepositoryTest( + ProfileRepository profileRepo, + CategoryRepository categoryRepo, + RecipeRepository recipeRepo, + LinkRecCatRepository linkRepo, + TransactionalOperator transactionalOp) + { + this.repo = categoryRepo; + this.rootStorage = new AsyncRootStorage(); + var layerFactory = new AsyncLayerFactory(rootStorage); + this.profileLayer = layerFactory.profileLayer(asyncPersistence(profileRepo)); + this.categoryLayer = layerFactory.categoryLayer(asyncPersistence(categoryRepo)); + this.recipeLayer = layerFactory.recipeLayer(asyncPersistence(recipeRepo)); + this.linkLayer = layerFactory.linkRecCatLayer(asyncPersistence(linkRepo)); + this.transaction = new Transaction(transactionalOp); + } + + @Test + void addCategory_validInitialCategory_savedCategory() { + CategorySlot category = categorySlot().blank(); + + var checks = defer(() -> repo.addCategory(category.initial())) + .zipWhen((Category actual) -> repo.findById(actual.id())); + + init(slot(category)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Category actual, Category saved) -> { + Category initial = category.initial(); + assertThat(actual.id()).isNotZero(); + assertThat(actual).usingRecursiveComparison().ignoringFields(Category.Fields.id).isEqualTo(initial); + assertThat(actual).usingRecursiveComparison().isEqualTo(saved); + })) + .verifyComplete(); + + } + + @Test + void addCategory_initialCategoryExistentName_errorUniqueNameConstraintException() { + CategorySlot category = categorySlot().blank(); + + var checks = defer(() -> repo.addCategory(category.initial())); + + save(slot(category)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void categoriesOutByRecipeId_existentRecipeId_categoriesOut() { + List recipes = recipeSlotsWithLinkCounts(2, 1).map(to::blank).toList(); + RecipeSlot recipe = recipes.getFirst(); + List categories = linkedCategorySlots(recipe).map(to::blank).toList(); + List allLinks = recipes.stream().flatMap(this::linkRecCatSlots).map(to::blank).toList(); + + var checks = defer(() -> repo.categoriesByRecipeId(recipe.id()).collectList()); + + save(slots(allLinks)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List actual) -> { + List saved = categories.stream().map(to::saved).toList(); + assertThat(actual).containsExactlyInAnyOrderElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void categoryIdByName_existentCategoryName_categoryId() { + List categories = categorySlots().limit(2).map(to::blank).toList(); + CategorySlot category = categories.getFirst(); + + var checks = defer(() -> repo.categoryIdByName(category.name())); + + save(slot(category)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((Long actualId) -> { + assertThat(actualId).isEqualTo(category.id()); + }) + .verifyComplete(); + } + + @Test + void categoryIdByName_nonExistentCategoryName_none() { + var checks = repo.categoryIdByName("not_a_category"); + + checks.as(transaction::rollbackVerify) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/category/CategoryServiceTest.java b/src/test/java/eu/bitfield/recipes/core/category/CategoryServiceTest.java new file mode 100644 index 0000000..6058dce --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/category/CategoryServiceTest.java @@ -0,0 +1,77 @@ +package eu.bitfield.recipes.core.category; + +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatQueries; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.List; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class CategoryServiceTest implements LinkRecCatQueries { + CategoryRepository repo = mock(CategoryRepository.class); + CategoryService serv = new CategoryService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + + @Test + void addCategories_categoriesIn_savedCategories() { + List categories = categorySlots().map(to::blank).toList(); + save(slots(categories)); + List saved = categories.stream().map(to::saved).toList(); + List categoriesIn = categories.stream().map(toCategoryIn).toList(); + int presentCount = categories.size() / 2; + + categories.stream().limit(presentCount).forEach((CategorySlot category) -> { + when(repo.categoryIdByName(category.name())).thenReturn(some(category.id())); + }); + categories.stream().skip(presentCount).forEach((CategorySlot category) -> { + when(repo.categoryIdByName(category.name())).thenReturn(none()); + when(repo.addCategory(category.initial())).thenReturn(some(category.saved())); + }); + + serv.addCategories(categoriesIn) + .as(StepVerifier::create) + .assertNext((List allActual) -> { + assertThat(allActual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void getCategories_existentRecipeId_linkedCategoriesOut() { + RecipeSlot recipe = recipeSlot().blank(); + List links = linkRecCatSlots(recipe).map(to::blank).toList(); + List categories = linkedCategorySlots(recipe).map(to::blank).toList(); + save(slots(links)); + List saved = categories.stream().map(to::saved).toList(); + + when(repo.categoriesByRecipeId(recipe.id())).thenReturn(flux(saved)); + + serv.getCategories(recipe.id()) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientRepositoryTest.java new file mode 100644 index 0000000..42fbe75 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientRepositoryTest.java @@ -0,0 +1,182 @@ +package eu.bitfield.recipes.core.ingredient; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.profile.ProfileRepository; +import eu.bitfield.recipes.core.recipe.RecipeRepository; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientQueries; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AsyncLayerFactory; +import eu.bitfield.recipes.test.data.AsyncRootStorage; +import eu.bitfield.recipes.util.Id; +import eu.bitfield.recipes.util.Transaction; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.reactive.TransactionalOperator; + +import java.util.List; + +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static java.util.Comparator.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +class IngredientRepositoryTest implements IngredientQueries { + final IngredientRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final @Delegate RecipeLayer recipeLayer; + final @Delegate IngredientLayer ingredientLayer; + final Transaction transaction; + + @Autowired + public IngredientRepositoryTest( + ProfileRepository profileRepo, + RecipeRepository recipeRepo, + IngredientRepository ingredientRepo, + TransactionalOperator transactionalOp) + { + this.repo = ingredientRepo; + this.rootStorage = new AsyncRootStorage(); + var layers = new AsyncLayerFactory(rootStorage); + this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo)); + this.recipeLayer = layers.recipeLayer(asyncPersistence(recipeRepo)); + this.ingredientLayer = layers.ingredientLayer(asyncPersistence(ingredientRepo)); + this.transaction = new Transaction(transactionalOp); + } + + @Test + void addIngredients_validInitialIngredients_savedIngredients() { + List ingredients = ingredientSlots(recipeSlot().blank()).limit(2).map(to::blank).toList(); + @NoArgsConstructor @Setter @Accessors(fluent = true) + class Check { + Ingredient initial, actual, saved; + } + + var checks = flux(ingredients) + .map(to::initial) + .collectList() + .zipWhen((List allInitial) -> repo.addIngredients(allInitial).collectList()) + .flatMapMany(function((List allInitial, List allActual) -> { + return flux(allInitial).zipWithIterable(allActual); + })) + .map(function((Ingredient initial, Ingredient actual) -> new Check().initial(initial).actual(actual))) + .concatMap((Check c) -> repo.findById(c.actual.id()).map(c::saved)) + .collectList(); + + save(slots(ingredients)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List allChecks) -> { + assertThat(allChecks).hasSameSizeAs(ingredients); + assertThat(allChecks).allSatisfy((Check c) -> { + assertThat(c.actual.id()).isNotZero(); + assertThat(c.actual).usingRecursiveComparison() + .ignoringFields(Ingredient.Fields.id) + .isEqualTo(c.initial); + assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved); + }); + }) + .verifyComplete(); + } + + @Test + void addIngredients_invalidInitialIngredientsNonExistentRecipe_errorForeignKeyConstraintViolation() { + IngredientSlot ingredient = ingredientSlot().blank(); + RecipeSlot recipe = ingredient.recipe(); + + var checks = many(ingredient) + .map(to::initial) + .collectList() + .flatMapMany(repo::addIngredients) + .then(); + + invalidate(recipe); + init(slot(ingredient)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void ingredientsOutByRecipeId_existentRecipeId_ingredientsOut() { + List groups = recipeIngredientGroupsWithIngredientCounts(2, 1); + List allIngredients = groups.stream() + .flatMap(RecipeIngredientGroup::ingredients) + .map(to::blank) + .toList(); + RecipeSlot recipe = groups.getFirst().recipe().blank(); + List ingredients = groups.getFirst().ingredients().map(to::blank).toList(); + + var checks = defer(() -> repo.ingredientsByRecipeId(recipe.id())).collectList(); + + save(slots(allIngredients)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List actual) -> { + List saved = ingredients.stream() + .sorted(comparing(Id::id)) + .map(to::saved) + .toList(); + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void ingredientsOutByRecipeId_nonExistentRecipeId_none() { + var checks = repo.ingredientsByRecipeId(freeId); + + checks.as(transaction::rollbackVerify) + .verifyComplete(); + } + + @Test + void removeIngredientsFromRecipe_existentRecipeId_removedCount() { + List groups = recipeIngredientGroupsWithIngredientCounts(2, 1); + List allIngredients = groups.stream() + .flatMap(RecipeIngredientGroup::ingredients) + .map(to::blank) + .toList(); + RecipeSlot recipe = groups.getFirst().recipe().blank(); + + var checks = some(recipe).map(toId).flatMap(repo::removeIngredientsFromRecipe) + .zipWhen((Long actualRemovedCount) -> repo.findAll().collectList()); + + save(slots(allIngredients)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Long actualRemovedCount, List allRemaining) -> { + assertThat(actualRemovedCount).isEqualTo(2); + assertThat(allRemaining).allSatisfy(remaining -> { + assertThat(remaining.recipeId()).isNotEqualTo(recipe.id()); + }); + })) + .verifyComplete(); + } + + @Test + void removeIngredientsFromRecipe_nonExistentRecipeId_zero() { + var checks = repo.removeIngredientsFromRecipe(freeId); + + checks.as(transaction::rollbackVerify) + .expectNext(0L) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientServiceTest.java b/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientServiceTest.java new file mode 100644 index 0000000..49151de --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/ingredient/IngredientServiceTest.java @@ -0,0 +1,92 @@ +package eu.bitfield.recipes.core.ingredient; + +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientQueries; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.List; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class IngredientServiceTest implements IngredientQueries { + IngredientRepository repo = mock(IngredientRepository.class); + IngredientService serv = new IngredientService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate IngredientLayer ingredientLayer = layers.ingredientLayer(); + + @Test + void addIngredients_ingredientsInOfExistentRecipe_savedIngredients() { + RecipeIngredientGroup group = recipeIngredientGroupWithIngredientCount(5); + RecipeSlot recipe = group.recipe().blank(); + List ingredients = group.ingredients().map(to::blank).toList(); + save(slot(recipe).add(ingredients)); + List initial = ingredients.stream().map(to::initial).toList(); + List saved = ingredients.stream().map(to::saved).toList(); + List savedIn = ingredients.stream().map(toIngredientIn).toList(); + + when(repo.addIngredients(initial)).thenReturn(flux(saved)); + + serv.addIngredients(recipe.id(), savedIn) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } + + @Test + void getIngredients_existentRecipeId_ingredients() { + RecipeIngredientGroup group = recipeIngredientGroupWithIngredientCount(5); + RecipeSlot recipe = group.recipe().blank(); + List ingredients = group.ingredients().map(to::blank).toList(); + save(slot(recipe).add(ingredients)); + List saved = ingredients.stream().map(to::saved).toList(); + + when(repo.ingredientsByRecipeId(recipe.id())).thenReturn(flux(saved)); + + serv.getIngredients(recipe.id()) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void updateIngredients_newIngredients_updatedIngredients() { + RecipeIngredientGroup group = recipeIngredientGroupWithIngredientCount(5); + RecipeSlot recipe = group.recipe().blank(); + List ingredients = group.ingredients().map(to::blank).toList(); + save(slot(recipe).add(ingredients)); + List initial = ingredients.stream().map(to::initial).toList(); + List saved = ingredients.stream().map(to::saved).toList(); + List savedIn = ingredients.stream().map(toIngredientIn).toList(); + long oldIngredientCount = saved.size() - 1; + + when(repo.removeIngredientsFromRecipe(recipe.id())).thenReturn(some(oldIngredientCount)); + when(repo.addIngredients(initial)).thenReturn(flux(saved)); + + serv.updateIngredients(recipe.id(), savedIn) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatRepositoryTest.java new file mode 100644 index 0000000..b159d66 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatRepositoryTest.java @@ -0,0 +1,167 @@ +package eu.bitfield.recipes.core.link; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.category.CategoryRepository; +import eu.bitfield.recipes.core.profile.ProfileRepository; +import eu.bitfield.recipes.core.recipe.RecipeRepository; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatQueries; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AsyncLayerFactory; +import eu.bitfield.recipes.test.data.AsyncRootStorage; +import eu.bitfield.recipes.test.data.Initial; +import eu.bitfield.recipes.util.Transaction; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.reactive.TransactionalOperator; + +import java.util.List; + +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +public class LinkRecCatRepositoryTest implements LinkRecCatQueries { + final LinkRecCatRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final @Delegate RecipeLayer recipeLayer; + final @Delegate CategoryLayer categoryLayer; + final @Delegate LinkRecCatLayer linkLayer; + final Transaction transaction; + + @Autowired + public LinkRecCatRepositoryTest( + ProfileRepository profileRepo, + RecipeRepository recipeRepo, + CategoryRepository categoryRepo, + LinkRecCatRepository linkRepo, + TransactionalOperator transactionalOp) + { + this.repo = linkRepo; + this.rootStorage = new AsyncRootStorage(); + var layerFactory = new AsyncLayerFactory(rootStorage); + this.profileLayer = layerFactory.profileLayer(asyncPersistence(profileRepo)); + this.categoryLayer = layerFactory.categoryLayer(asyncPersistence(categoryRepo)); + this.recipeLayer = layerFactory.recipeLayer(asyncPersistence(recipeRepo)); + this.linkLayer = layerFactory.linkRecCatLayer(asyncPersistence(linkRepo)); + this.transaction = new Transaction(transactionalOp); + } + + + @Test + void addLinks_validInitialLinks_savedLinks() { + List links = linkRecCatSlots().limit(2).map(to::blank).toList(); + @NoArgsConstructor @Setter @Accessors(fluent = true) + class Check { + LinkRecCat initial, actual, saved; + } + + var checks = flux(links) + .map(to::initial) + .collectList() + .zipWhen((List allInitial) -> repo.addLinks(allInitial).collectList()) + .flatMapMany(function((List allInitial, List allActual) -> { + return flux(allInitial).zipWithIterable(allActual); + })) + .map(function((LinkRecCat initial, LinkRecCat actual) -> new Check().initial(initial).actual(actual))) + .concatMap((Check c) -> repo.findById(c.actual.id()).map(c::saved)) + .collectList(); + + init(slots(links)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List allChecks) -> { + assertThat(allChecks).hasSameSizeAs(links); + assertThat(allChecks).allSatisfy((Check c) -> { + assertThat(c.actual.id()).isNotZero(); + assertThat(c.actual).usingRecursiveComparison() + .ignoringFields(LinkRecCat.Fields.id) + .isEqualTo(c.initial); + assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved); + }); + }) + .verifyComplete(); + } + + @Test + void addLinks_invalidInitialLinksNonExistentCategory_errorForeignKeyConstraintViolation() { + LinkRecCatSlot link = linkRecCatSlots().findFirst().orElseThrow().blank(); + + var checks = many(link) + .map(Initial::initial) + .collectList() + .flatMapMany(repo::addLinks) + .then(); + + invalidate(link.category()); + init(slot(link)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void addLinks_invalidInitialLinksNonExistentRecipe_errorForeignKeyConstraintViolation() { + LinkRecCatSlot link = linkRecCatSlots().findFirst().orElseThrow().blank(); + + var checks = many(link) + .map(Initial::initial) + .collectList() + .flatMapMany(repo::addLinks) + .then(); + + invalidate(link.recipe()); + init(slot(link)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void addLinks_validInitialLinksDuplicate_errorUniqueConstraintViolation() { + LinkRecCatSlot link = linkRecCatSlots().findFirst().orElseThrow().blank(); + + var checks = many(link) + .map(Initial::initial) + .collectList() + .flatMapMany(repo::addLinks) + .then(); + + save(slot(link)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void removeCategories_existentRecipe_removedCategoryCount() { + List recipes = recipeSlotsWithLinkCounts(2, 1).map(to::blank).toList(); + RecipeSlot recipeForRemoval = recipes.getFirst(); + List links = recipes.stream().flatMap(this::linkRecCatSlots).map(to::blank).toList(); + + var checks = some(recipeForRemoval).map(toId).flatMap(repo::removeCategoriesFromRecipe); + + save(slots(links)) + .then(checks) + .as(transaction::rollbackVerify) + .expectNext(2L) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatServiceTest.java b/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatServiceTest.java new file mode 100644 index 0000000..b8ae829 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/link/LinkRecCatServiceTest.java @@ -0,0 +1,76 @@ +package eu.bitfield.recipes.core.link; + +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatQueries; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.List; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class LinkRecCatServiceTest implements LinkRecCatQueries { + LinkRecCatRepository repo = mock(LinkRecCatRepository.class); + LinkRecCatService serv = new LinkRecCatService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + + @Test + void addLinks_existentRecipeCategories_savedLinkRecCats() { + RecipeSlot recipe = recipeSlotWithLinkCount(1).blank(); + List links = linkRecCatSlots(recipe).map(to::blank).toList(); + save(slots(links)); + List categories = links.stream().map(LinkRecCatSlot::category).map(to::saved).toList(); + List initial = links.stream().map(to::initial).toList(); + List saved = links.stream().map(to::saved).toList(); + + when(repo.addLinks(initial)).thenReturn(flux(saved)); + + serv.addLinks(recipe.id(), categories) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void updateLinks_newExistentRecipeCategories_updatedLinkRecCats() { + RecipeSlot recipe = recipeSlotWithLinkCount(1).blank(); + List links = linkRecCatSlots(recipe).map(to::blank).toList(); + save(slots(links)); + List categories = links.stream().map(LinkRecCatSlot::category).map(to::saved).toList(); + List initial = links.stream().map(to::initial).toList(); + List saved = links.stream().map(to::saved).toList(); + long categoryCount = categories.size(); + long oldCategoryCount = categoryCount - 1; + + when(repo.removeCategoriesFromRecipe(recipe.id())).thenReturn(some(oldCategoryCount)); + when(repo.addLinks(initial)).thenReturn(flux(saved)); + + serv.updateLinks(recipe.id(), categories) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/profile/ProfileRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/profile/ProfileRepositoryTest.java new file mode 100644 index 0000000..31153eb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/profile/ProfileRepositoryTest.java @@ -0,0 +1,58 @@ +package eu.bitfield.recipes.core.profile; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileQueries; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.data.AsyncLayerFactory; +import eu.bitfield.recipes.test.data.AsyncRootStorage; +import eu.bitfield.recipes.util.Transaction; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.reactive.TransactionalOperator; + +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +class ProfileRepositoryTest implements ProfileQueries { + final ProfileRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final Transaction transaction; + + @Autowired + public ProfileRepositoryTest(ProfileRepository profileRepo, TransactionalOperator transactionalOperator) { + this.repo = profileRepo; + this.rootStorage = new AsyncRootStorage(); + var layers = new AsyncLayerFactory(rootStorage); + this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo)); + this.transaction = new Transaction(transactionalOperator); + } + + @Test + void addProfile_initialProfile_savedProfile() { + ProfileSlot profile = profileSlot().blank(); + + var checks = defer(() -> repo.addProfile(profile.initial())) + .zipWhen(actual -> repo.findById(actual.id())); + + save(slot(profile)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Profile actual, Profile saved) -> { + Profile initial = profile.initial(); + assertThat(actual.id()).isNotZero(); + assertThat(actual).usingRecursiveComparison().ignoringFields(Profile.Fields.id).isEqualTo(initial); + assertThat(actual).usingRecursiveComparison().isEqualTo(saved); + })) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/profile/ProfileServiceTest.java b/src/test/java/eu/bitfield/recipes/core/profile/ProfileServiceTest.java new file mode 100644 index 0000000..8d5360f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/profile/ProfileServiceTest.java @@ -0,0 +1,39 @@ +package eu.bitfield.recipes.core.profile; + +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileQueries; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class ProfileServiceTest implements ProfileQueries { + ProfileRepository repo = mock(ProfileRepository.class); + ProfileService serv = new ProfileService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + + @Test + void addProfile_initialProfile_savedProfile() { + ProfileSlot profile = profileSlot().blank(); + save(slot(profile)); + + when(repo.addProfile(profile.initial())).thenReturn(some(profile.saved())); + + serv.addProfile() + .as(StepVerifier::create) + .assertNext((Profile added) -> { + assertThat(added).isEqualTo(profile.saved()); + }) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/recipe/RecipeRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/recipe/RecipeRepositoryTest.java new file mode 100644 index 0000000..b8a00e5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/recipe/RecipeRepositoryTest.java @@ -0,0 +1,305 @@ +package eu.bitfield.recipes.core.recipe; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.category.CategoryRepository; +import eu.bitfield.recipes.core.link.LinkRecCatRepository; +import eu.bitfield.recipes.core.profile.ProfileRepository; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatQueries; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AsyncLayerFactory; +import eu.bitfield.recipes.test.data.AsyncRootStorage; +import eu.bitfield.recipes.util.Transaction; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.util.Pair; +import org.springframework.transaction.reactive.TransactionalOperator; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.api.recipe.RecipeEndpoint.*; +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.AsyncUtils.defer; +import static eu.bitfield.recipes.util.TestUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.core.publisher.Flux.*; +import static reactor.function.TupleUtils.*; + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +public class RecipeRepositoryTest implements LinkRecCatQueries { + static final long limit = MAX_LIMIT; + static final long offset = 0; + + final RecipeRepository repo; + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final @Delegate RecipeLayer recipeLayer; + final @Delegate CategoryLayer categoryLayer; + final @Delegate LinkRecCatLayer linkLayer; + final Transaction transaction; + + @Autowired + public RecipeRepositoryTest( + ProfileRepository profileRepo, + RecipeRepository recipeRepo, + CategoryRepository categoryRepo, + LinkRecCatRepository linkRepo, + TransactionalOperator transactionalOp) + { + this.repo = recipeRepo; + this.rootStorage = new AsyncRootStorage(); + var layerFactory = new AsyncLayerFactory(rootStorage); + this.profileLayer = layerFactory.profileLayer(asyncPersistence(profileRepo)); + this.categoryLayer = layerFactory.categoryLayer(asyncPersistence(categoryRepo)); + this.recipeLayer = layerFactory.recipeLayer(asyncPersistence(recipeRepo)); + this.linkLayer = layerFactory.linkRecCatLayer(asyncPersistence(linkRepo)); + this.transaction = new Transaction(transactionalOp); + } + + @Test + void addRecipe_validInitialRecipe_savedRecipe() { + RecipeSlot recipe = recipeSlot().blank(); + + var checks = defer(() -> repo.addRecipe(recipe.initial())) + .zipWhen(actual -> repo.findById(actual.id())); + + init(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Recipe actual, Recipe saved) -> { + Recipe initial = recipe.initial(); + assertThat(actual.id()).isNotZero(); + assertThat(actual).usingRecursiveComparison().ignoringFields(Recipe.Fields.id).isEqualTo(initial); + assertThat(actual).usingRecursiveComparison().isEqualTo(saved); + })) + .verifyComplete(); + } + + @Test + void addRecipe_invalidInitialRecipeNonExistentProfileId_errorForeignKeyConstraintViolation() { + RecipeSlot recipe = recipeSlot().blank(); + + var checks = defer(() -> repo.addRecipe(recipe.initial())); + + invalidate(recipe.author()); + init(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void recipeOut_existentRecipeId_recipeOut() { + List recipes = recipeSlots().limit(2).map(to::blank).toList(); + RecipeSlot recipe = recipes.getFirst(); + + var checks = defer(() -> repo.recipe(recipe.id())); + + save(slots(recipes)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((Recipe actual) -> { + assertThat(actual).usingRecursiveComparison().isEqualTo(recipe.saved()); + }) + .verifyComplete(); + } + + @Test + void recipeOut_nonExistentRecipeId_none() { + var checks = repo.recipe(freeId); + + checks.as(transaction::rollbackVerify) + .verifyComplete(); + } + + @Test + void updateRecipe_existentRecipeUpdate_true() { + RecipeSlot recipe = recipeSlot().blank(); + @NoArgsConstructor @AllArgsConstructor @Setter @Accessors(fluent = true) + class Check { + Recipe update; + Boolean rowsChanged; + Recipe after; + } + + var checks = defer(() -> { + Instant changedAt = realTime().now(); + Recipe saved = recipe.saved(); + String name = saved.name() + "...new1"; + String description = saved.description() + "...new2"; + Recipe update = new Recipe(saved.id(), saved.authorProfileId(), name, description, changedAt); + return supply(() -> new Check().update(update)) + .zipWhen((Check c) -> repo.updateRecipe(saved.id(), name, description, changedAt), Check::rowsChanged); + }) + .zipWhen((Check c) -> repo.findById(recipe.id()), Check::after); + + save(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((Check c) -> { + assertThat(c.rowsChanged).isTrue(); + assertThat(c.update).usingRecursiveComparison().isEqualTo(c.after); + }) + .verifyComplete(); + } + + @Test + void updateRecipe_nonExistentRecipeUpdate_false() { + Instant changedAt = realTime().now(); + long id = freeId; + String name = "name"; + String description = "description"; + + var checks = repo.updateRecipe(id, name, description, changedAt) + .zipWhen((Boolean rowsChanged) -> repo.findById(id).singleOptional()); + + + checks.as(transaction::rollbackVerify) + .assertNext(consumer((Boolean rowsChanged, Optional after) -> { + assertThat(rowsChanged).isFalse(); + assertThat(after).isNotPresent(); + })) + .verifyComplete(); + } + + @Test + void removeRecipe_existentRecipeId_true() { + RecipeSlot recipe = recipeSlot().blank(); + + var checks = defer(() -> repo.removeRecipe(recipe.id()) + .zipWhen((Boolean rowsChanged) -> repo.findById(recipe.id()).singleOptional())); + + save(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Boolean rowsChanged, Optional after) -> { + assertThat(rowsChanged).isTrue(); + assertThat(after).isNotPresent(); + })) + .verifyComplete(); + } + + @Test + void removeRecipe_nonExistentRecipeId_false() { + var checks = repo.removeRecipe(freeId); + + checks.as(transaction::rollbackVerify) + .expectNext(false) + .verifyComplete(); + } + + @Test + void recipeIds_someCategoryName$noRecipeName_recipeIds() { + List allCategories = categorySlotsWithLinkCounts(2, 1, 0).map(to::blank).toList(); + Map> recipesByCategory = allCategories.stream() + .map((CategorySlot category) -> Pair.of(category, linkedRecipeSlots(category).map(to::blank).toList())) + .collect(Pair.toMap()); + List links = allCategories.stream().flatMap(this::linkRecCatSlots).map(to::blank).toList(); + String recipeName = null; + + var checks = flux(allCategories) + .map(CategorySlot::name) + .concatMap((String categoryName) -> many(categoryName, categoryName.toUpperCase())) + .concatMap((String categoryName) -> { + List categories = allCategories.stream().filter(hasCategoryName(categoryName)).toList(); + var savedRecipeIds = flux(categories) + .concatMap((CategorySlot category) -> flux(recipesByCategory.get(category))) + .distinct() + .sort(orderByRecipeChangedAtDesc()) + .map(toId) + .collectList(); + return repo.recipeIds(categoryName, recipeName, limit, offset).collectList() + .zipWhen((List actualRecipeIds) -> savedRecipeIds); + }) + .collectList(); + + save(slots(links).add(allCategories)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(thatAllSatisfy(consumer((List actualRecipeIds, List savedRecipeIds) -> { + assertThat(actualRecipeIds).containsExactlyElementsOf(savedRecipeIds); + }))) + .verifyComplete(); + } + + @Test + void recipeIds_noCategoryName$someRecipeName_recipeIds() { + List allRecipes = Stream.of(recipes.muhammara, recipes.tomatoSalad, recipes.hummus) + .map(this::recipeSlot) + .map(to::blank) + .toList(); + String categoryName = null; + + var checks = concat(flux(allRecipes).map(RecipeSlot::name), many("ma", "pears")) + .concatMap(recipeName -> many(recipeName, recipeName.toUpperCase())) + .concatMap(recipeName -> { + var savedRecipeIds = flux(allRecipes) + .filter(containsRecipeName(recipeName)) + .sort(orderByRecipeChangedAtDesc()) + .map(toId) + .collectList(); + return repo.recipeIds(categoryName, recipeName, limit, offset).collectList() + .zipWhen((List actualRecipeIds) -> savedRecipeIds); + }) + .collectList(); + save(slots(allRecipes)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(thatAllSatisfy(consumer((List actualRecipeIds, List savedRecipeIds) -> { + assertThat(actualRecipeIds).containsExactlyElementsOf(savedRecipeIds); + }))) + .verifyComplete(); + } + + @Test + void canEditRecipe_recipeAuthorId_true() { + RecipeSlot recipe = recipeSlot().blank(); + + var checks = defer(() -> repo.canEditRecipe(recipe.id(), recipe.author().id())); + + save(slot(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .expectNext(true) + .verifyComplete(); + } + + @Test + void canEditRecipe_recipeNotAuthorId_false() { + ProfileSlot author = profileSlot(profiles.ada).blank(); + ProfileSlot notAuthor = profileSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author).blank(); + + var checks = defer(() -> repo.canEditRecipe(recipe.id(), notAuthor.id())); + + save(slots(author, notAuthor).add(recipe)) + .then(checks) + .as(transaction::rollbackVerify) + .expectNext(false) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/recipe/RecipeServiceTest.java b/src/test/java/eu/bitfield/recipes/core/recipe/RecipeServiceTest.java new file mode 100644 index 0000000..aee55d3 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/recipe/RecipeServiceTest.java @@ -0,0 +1,220 @@ +package eu.bitfield.recipes.core.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeService.RecipeNotFound; +import eu.bitfield.recipes.core.recipe.RecipeService.RemoveRecipeForbidden; +import eu.bitfield.recipes.core.recipe.RecipeService.UpdateRecipeForbidden; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatQueries; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import eu.bitfield.recipes.util.Pagination; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.TestUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class RecipeServiceTest implements LinkRecCatQueries { + RecipeRepository repo = mock(RecipeRepository.class); + RecipeService serv = new RecipeService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate CategoryLayer categoryLayer = layers.categoryLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate LinkRecCatLayer linkRecCatLayer = layers.linkRecCatLayer(); + + @Test + void addRecipe_recipeInAndIdOfExistentAuthorProfile_savedRecipe() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + RecipeIn recipeIn = recipe.toRecipeIn(); + Instant createdAt = recipe.saved().changedAt(); + + when(repo.addRecipe(recipe.initial())).thenReturn(some(recipe.saved())); + + serv.addRecipe(recipe.author().id(), recipeIn, createdAt) + .as(StepVerifier::create) + .expectNext(recipe.saved()) + .verifyComplete(); + } + + @Test + void getRecipe_nonExistentRecipeId_errorRecipeNotFound() { + long recipeId = freeId; + + when(repo.recipe(recipeId)).thenReturn(none()); + + serv.getRecipe(recipeId) + .as(StepVerifier::create) + .verifyError(RecipeNotFound.class); + } + + @Test + void getRecipe_existentRecipeId_recipeOut() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + + when(repo.recipe(recipe.id())).thenReturn(some(recipe.saved())); + + serv.getRecipe(recipe.id()) + .as(StepVerifier::create) + .expectNext(recipe.saved()) + .verifyComplete(); + } + + @Test + void findRecipeIds_categoryName_recipeIds() { + CategorySlot category = categorySlotWithLinkCount(2).blank(); + List recipes = linkedRecipeSlots(category).map(to::blank).toList(); + save(slot(category).add(recipes)); + List savedIds = recipes.stream().sorted(orderByRecipeChangedAtDesc()).map(toId).toList(); + long limit = savedIds.size(); + long offset = 0; + var pagination = new Pagination(limit, offset); + + when(repo.recipeIds(category.name(), null, limit, offset)).thenReturn(flux(savedIds)); + + serv.findRecipeIds(category.name(), null, pagination) + .collectList() + .as(StepVerifier::create) + .assertNext((List actualIds) -> { + assertThat(actualIds).containsExactlyElementsOf(savedIds); + }).verifyComplete(); + } + + + @Test + void findRecipeIds_recipeNamePart_recipeIds() { + String recipeName = "minestro"; + List recipes = recipeSlots().map(to::blank).toList(); + save(slots(recipes)); + Stream matchingRecipes = recipes.stream().filter(containsRecipeName(recipeName)); + List savedIds = matchingRecipes.sorted(orderByRecipeChangedAtDesc()).map(toId).toList(); + long limit = savedIds.size(); + long offset = 0; + var pagination = new Pagination(limit, offset); + + when(repo.recipeIds(null, recipeName, limit, offset)).thenReturn(flux(savedIds)); + + serv.findRecipeIds(null, recipeName, pagination) + .collectList() + .as(StepVerifier::create) + .assertNext((List actualIds) -> { + assertThat(actualIds).containsExactlyElementsOf(savedIds); + }) + .verifyComplete(); + } + + @Test + void updateRecipe_nonExistentRecipeId_errorRecipeNotFound() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + RecipeIn recipeIn = recipe.toRecipeIn(); + long recipeId = freeId; + long profileId = recipe.author().id(); + Instant changedAt = realTime().now(); + + when(repo.canEditRecipe(recipeId, profileId)).thenReturn(none()); + when(repo.updateRecipe(anyLong(), any(), any(), any())).thenReturn(noSubscriptionMono()); + + serv.updateRecipe(recipeId, profileId, recipeIn, changedAt) + .as(StepVerifier::create) + .verifyError(RecipeNotFound.class); + } + + @Test + void updateRecipe_notAuthorProfileId_errorUpdateRecipeForbidden() { + ProfileSlot author = profileSlot(profiles.ada).blank(); + ProfileSlot notAuthor = profileSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author).blank(); + save(slots(author, notAuthor).add(recipe)); + RecipeIn recipeIn = recipe.toRecipeIn(); + Instant changedAt = realTime().now(); + + when(repo.canEditRecipe(recipe.id(), notAuthor.id())).thenReturn(some(false)); + when(repo.updateRecipe(anyLong(), any(), any(), any())).thenReturn(noSubscriptionMono()); + + serv.updateRecipe(recipe.id(), notAuthor.id(), recipeIn, changedAt) + .as(StepVerifier::create) + .verifyError(UpdateRecipeForbidden.class); + } + + @Test + void updateRecipe_anyRecipeIn_updatedRecipe() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + RecipeIn recipeIn = recipe.toRecipeIn(); + Instant changedAt = recipe.saved().changedAt(); + + when(repo.canEditRecipe(recipe.id(), recipe.author().id())).thenReturn(some(true)); + when(repo.updateRecipe(recipe.id(), recipeIn.name(), recipeIn.description(), changedAt)).thenReturn(some(true)); + + serv.updateRecipe(recipe.id(), recipe.author().id(), recipeIn, changedAt) + .as(StepVerifier::create) + .expectNext(recipe.saved()) + .verifyComplete(); + } + + @Test + void removeRecipe_nonExistentRecipeId_errorRecipeNotFound() { + long recipeId = freeId; + ProfileSlot profile = profileSlot().blank(); + save(slot(profile)); + + when(repo.canEditRecipe(recipeId, profile.id())).thenReturn(none()); + when(repo.removeRecipe(anyLong())).thenReturn(noSubscriptionMono()); + + serv.removeRecipe(recipeId, profile.id()) + .as(StepVerifier::create) + .verifyError(RecipeNotFound.class); + } + + @Test + void removeRecipe_notAuthorProfileId_errorRemoveRecipeForbidden() { + ProfileSlot author = profileSlot(profiles.ada).blank(); + ProfileSlot notAuthor = profileSlot(profiles.bea).blank(); + RecipeSlot recipe = recipeSlot().author(author).blank(); + save(slots(author, notAuthor).add(recipe)); + + when(repo.canEditRecipe(recipe.id(), notAuthor.id())).thenReturn(some(false)); + when(repo.removeRecipe(anyLong())).thenReturn(noSubscriptionMono()); + + serv.removeRecipe(recipe.id(), notAuthor.id()) + .as(StepVerifier::create) + .verifyError(RemoveRecipeForbidden.class); + } + + @Test + void removeRecipe_existentRecipeId_true() { + RecipeSlot recipe = recipeSlot().blank(); + save(slot(recipe)); + + when(repo.canEditRecipe(recipe.id(), recipe.author().id())).thenReturn(some(true)); + when(repo.removeRecipe(anyLong())).thenReturn(some(true)); + + serv.removeRecipe(recipe.id(), recipe.author().id()) + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/step/StepRepositoryTest.java b/src/test/java/eu/bitfield/recipes/core/step/StepRepositoryTest.java new file mode 100644 index 0000000..af4242a --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/step/StepRepositoryTest.java @@ -0,0 +1,183 @@ +package eu.bitfield.recipes.core.step; + +import eu.bitfield.recipes.config.DatabaseConfiguration; +import eu.bitfield.recipes.core.profile.ProfileRepository; +import eu.bitfield.recipes.core.recipe.RecipeRepository; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.core.step.StepQueries; +import eu.bitfield.recipes.test.core.step.StepSlot; +import eu.bitfield.recipes.test.data.AsyncLayerFactory; +import eu.bitfield.recipes.test.data.AsyncRootStorage; +import eu.bitfield.recipes.util.Id; +import eu.bitfield.recipes.util.Transaction; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.r2dbc.DataR2dbcTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.reactive.TransactionalOperator; + +import java.util.List; + +import static eu.bitfield.recipes.test.AsyncPersistence.*; +import static eu.bitfield.recipes.test.InvalidEntity.*; +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static java.util.Comparator.*; +import static org.assertj.core.api.Assertions.*; +import static reactor.function.TupleUtils.*; + + +@Import(DatabaseConfiguration.class) @DataR2dbcTest +class StepRepositoryTest implements StepQueries { + final StepRepository repo; + + final @Delegate AsyncRootStorage rootStorage; + final @Delegate ProfileLayer profileLayer; + final @Delegate RecipeLayer recipeLayer; + final @Delegate StepLayer stepLayer; + final Transaction transaction; + + @Autowired + public StepRepositoryTest( + ProfileRepository profileRepo, + RecipeRepository recipeRepo, + StepRepository stepRepo, + TransactionalOperator transactionalOp) + { + this.repo = stepRepo; + this.rootStorage = new AsyncRootStorage(); + var layers = new AsyncLayerFactory(rootStorage); + this.profileLayer = layers.profileLayer(asyncPersistence(profileRepo)); + this.recipeLayer = layers.recipeLayer(asyncPersistence(recipeRepo)); + this.stepLayer = layers.stepLayer(asyncPersistence(stepRepo)); + this.transaction = new Transaction(transactionalOp); + } + + @Test + void addSteps_validInitialSteps_savedSteps() { + List steps = stepSlots(recipeSlot().blank()).limit(2).map(to::blank).toList(); + @NoArgsConstructor @Setter @Accessors(fluent = true) + class Check { + Step initial, actual, saved; + } + + var checks = flux(steps) + .map(to::initial) + .collectList() + .zipWhen((List allInitial) -> repo.addSteps(allInitial).collectList()) + .flatMapMany(function((List allInitial, List allActual) -> { + return flux(allInitial).zipWithIterable(allActual); + })) + .map(function((Step initial, Step actual) -> new Check().initial(initial).actual(actual))) + .concatMap((Check c) -> repo.findById(c.actual.id()).map(c::saved)) + .collectList(); + + save(slots(steps)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List allChecks) -> { + assertThat(allChecks).hasSameSizeAs(steps); + assertThat(allChecks).allSatisfy((Check c) -> { + assertThat(c.actual.id()).isNotZero(); + assertThat(c.actual).usingRecursiveComparison() + .ignoringFields(Step.Fields.id) + .isEqualTo(c.initial); + assertThat(c.actual).usingRecursiveComparison().isEqualTo(c.saved); + }); + }) + .verifyComplete(); + } + + @Test + void addSteps_invalidInitialStepsNonExistentRecipe_errorForeignKeyConstraintViolation() { + StepSlot step = stepSlot().blank(); + RecipeSlot recipe = step.recipe(); + + var checks = many(step) + .map(to::initial) + .collectList() + .flatMapMany(repo::addSteps) + .then(); + + invalidate(recipe); + init(slot(step)) + .then(checks) + .as(transaction::rollbackVerify) + .verifyError(DataIntegrityViolationException.class); + } + + @Test + void stepsOutByRecipeId_existentRecipeId_stepsOut() { + List groups = recipeStepGroupsWithStepCounts(2, 1); + List allSteps = groups.stream() + .flatMap(RecipeStepGroup::steps) + .map(to::blank) + .toList(); + RecipeSlot recipe = groups.getFirst().recipe().blank(); + List steps = groups.getFirst().steps().map(to::blank).toList(); + + var checks = defer(() -> repo.stepsByRecipeId(recipe.id())).collectList(); + + save(slots(allSteps)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext((List actual) -> { + List saved = steps.stream() + .sorted(comparing(Id::id)) + .map(to::saved) + .toList(); + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void stepsOutByRecipeId_nonExistentRecipeId_none() { + var checks = repo.stepsByRecipeId(freeId); + + checks.as(transaction::rollbackVerify) + .verifyComplete(); + } + + @Test + void removeStepsFromRecipe_existentRecipeId_removedCount() { + List groups = recipeStepGroupsWithStepCounts(2, 1); + List allSteps = groups.stream() + .flatMap(RecipeStepGroup::steps) + .map(to::blank) + .toList(); + RecipeSlot recipe = groups.getFirst().recipe().blank(); + + var checks = some(recipe).map(toId).flatMap(repo::removeStepsFromRecipe) + .zipWhen((Long actualRemovedCount) -> repo.findAll().collectList()); + + save(slots(allSteps)) + .then(checks) + .as(transaction::rollbackVerify) + .assertNext(consumer((Long actualRemovedCount, List allRemaining) -> { + assertThat(actualRemovedCount).isEqualTo(2); + assertThat(allRemaining).allSatisfy(remaining -> { + assertThat(remaining.recipeId()).isNotEqualTo(recipe.id()); + }); + })) + .verifyComplete(); + } + + @Test + void removeStepsFromRecipe_nonExistentRecipeId_zero() { + var checks = repo.removeStepsFromRecipe(freeId); + + checks.as(transaction::rollbackVerify) + .expectNext(0L) + .verifyComplete(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/core/step/StepServiceTest.java b/src/test/java/eu/bitfield/recipes/core/step/StepServiceTest.java new file mode 100644 index 0000000..88b0c53 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/core/step/StepServiceTest.java @@ -0,0 +1,92 @@ +package eu.bitfield.recipes.core.step; + +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.core.step.StepQueries; +import eu.bitfield.recipes.test.core.step.StepSlot; +import eu.bitfield.recipes.test.data.LayerFactory; +import eu.bitfield.recipes.test.data.RootStorage; +import lombok.experimental.Delegate; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.List; + +import static eu.bitfield.recipes.test.data.EntitySlots.*; +import static eu.bitfield.recipes.util.AsyncUtils.*; +import static eu.bitfield.recipes.util.To.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class StepServiceTest implements StepQueries { + StepRepository repo = mock(StepRepository.class); + StepService serv = new StepService(repo); + + @Delegate RootStorage rootStorage = new RootStorage(); + LayerFactory layers = new LayerFactory(rootStorage); + @Delegate ProfileLayer profileLayer = layers.profileLayer(); + @Delegate RecipeLayer recipeLayer = layers.recipeLayer(); + @Delegate StepLayer stepLayer = layers.stepLayer(); + + @Test + void addSteps_stepsInOfExistentRecipe_savedSteps() { + RecipeStepGroup group = recipeStepGroupWithStepCount(5); + RecipeSlot recipe = group.recipe().blank(); + List steps = group.steps().map(to::blank).toList(); + save(slot(recipe).add(steps)); + List initial = steps.stream().map(to::initial).toList(); + List saved = steps.stream().map(to::saved).toList(); + List savedIn = steps.stream().map(toStepIn).toList(); + + when(repo.addSteps(initial)).thenReturn(flux(saved)); + + serv.addSteps(recipe.id(), savedIn) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } + + @Test + void getSteps_existentRecipeId_stepsOut() { + RecipeStepGroup group = recipeStepGroupWithStepCount(5); + RecipeSlot recipe = group.recipe().blank(); + List steps = group.steps().map(to::blank).toList(); + save(slot(recipe).add(steps)); + List saved = steps.stream().map(to::saved).toList(); + + when(repo.stepsByRecipeId(recipe.id())).thenReturn(flux(saved)); + + serv.getSteps(recipe.id()) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }) + .verifyComplete(); + } + + @Test + void updateSteps_newSteps_updatedSteps() { + RecipeStepGroup group = recipeStepGroupWithStepCount(5); + RecipeSlot recipe = group.recipe().blank(); + List steps = group.steps().map(to::blank).toList(); + save(slot(recipe).add(steps)); + List initial = steps.stream().map(to::initial).toList(); + List saved = steps.stream().map(to::saved).toList(); + List savedIn = steps.stream().map(toStepIn).toList(); + long oldStepCount = saved.size() - 1; + + when(repo.removeStepsFromRecipe(recipe.id())).thenReturn(some(oldStepCount)); + when(repo.addSteps(initial)).thenReturn(flux(saved)); + + serv.updateSteps(recipe.id(), savedIn) + .as(StepVerifier::create) + .assertNext((List actual) -> { + assertThat(actual).containsExactlyElementsOf(saved); + }); + + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/AsyncPersistence.java b/src/test/java/eu/bitfield/recipes/test/AsyncPersistence.java new file mode 100644 index 0000000..fb59dd1 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/AsyncPersistence.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.test; + +import eu.bitfield.recipes.util.Entity; +import lombok.RequiredArgsConstructor; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Mono; + +public interface AsyncPersistence { + static AsyncPersistence asyncPersistence(ReactiveCrudRepository repo) { + return new ReactiveCrudRepositoryPersistence<>(repo); + } + + Mono save(E initial); +} + +@RequiredArgsConstructor +class ReactiveCrudRepositoryPersistence implements AsyncPersistence { + private final ReactiveCrudRepository repo; + + public Mono save(E initial) {return repo.save(initial);} +} diff --git a/src/test/java/eu/bitfield/recipes/test/InvalidEntity.java b/src/test/java/eu/bitfield/recipes/test/InvalidEntity.java new file mode 100644 index 0000000..34ae873 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/InvalidEntity.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test; + +public class InvalidEntity { + public static long freeId = Long.MAX_VALUE; +} diff --git a/src/test/java/eu/bitfield/recipes/test/Persistence.java b/src/test/java/eu/bitfield/recipes/test/Persistence.java new file mode 100644 index 0000000..2dfa608 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/Persistence.java @@ -0,0 +1,31 @@ +package eu.bitfield.recipes.test; + +import eu.bitfield.recipes.util.Entity; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import reactor.core.publisher.Flux; + +public interface Persistence { + static Persistence persistence(PersistWithId persistWithId) { + return new InMemoryIdSequencePersistence<>(persistWithId); + } + + E save(E initial); + + default Flux saveAll(Flux initial) { + return initial.map(this::save); + } + + @FunctionalInterface + interface PersistWithId { + E persist(E initial, long id); + } +} + +@RequiredArgsConstructor +class InMemoryIdSequencePersistence implements Persistence { + private final @Delegate PersistWithId persistWithId; + private long maxId = 0; + + public E save(E initial) {return persist(initial, ++maxId);} +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/APICall.java b/src/test/java/eu/bitfield/recipes/test/api/APICall.java new file mode 100644 index 0000000..4529c0d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/APICall.java @@ -0,0 +1,227 @@ +package eu.bitfield.recipes.test.api; + +import eu.bitfield.recipes.test.auth.ToAuth; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClient.RequestBodySpec; +import org.springframework.test.web.reactive.server.WebTestClient.RequestBodyUriSpec; +import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec; +import org.springframework.web.util.UriBuilder; + +import java.net.URI; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import static eu.bitfield.recipes.test.api.APICall.RequestBody.*; +import static eu.bitfield.recipes.test.api.APICall.RequestHeaders.*; +import static eu.bitfield.recipes.test.api.APICall.RequestMethod.*; +import static eu.bitfield.recipes.test.api.APICall.RequestURI.*; +import static eu.bitfield.recipes.test.api.APICall.ResponseBody.*; +import static eu.bitfield.recipes.test.api.APICall.ResponseStatus.*; +import static eu.bitfield.recipes.util.TestUtils.*; +import static java.util.Objects.*; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.http.HttpMethod.*; +import static org.springframework.http.HttpStatus.*; + +@NoArgsConstructor @Slf4j +public abstract class APICall { + Consumer authHeaders = null; + + protected static EntityExchangeResult log(EntityExchangeResult result) { + log.debug("{}", result); + return result; + } + + protected Consumer authHeaders() { + return assertNotNull(authHeaders); + } + + protected RequestMethod request(WebTestClient client) { + return requestMethod(client); + } + + @AllArgsConstructor + protected static class RequestMethod { + private WebTestClient client; + + public static RequestMethod requestMethod(WebTestClient client) { + return new RequestMethod(client); + } + + RequestURI method(HttpMethod method) { + return requestUri(client.method(method)); + } + + public RequestURI get() { + return method(GET); + } + + public RequestURI head() { + return method(HEAD); + } + + public RequestURI post() { + return method(POST); + } + + public RequestURI put() { + return method(PUT); + } + + public RequestURI patch() { + return method(PATCH); + } + + public RequestURI delete() { + return method(DELETE); + } + + public RequestURI options() { + return method(OPTIONS); + } + + public RequestURI trace() { + return method(TRACE); + } + } + + @AllArgsConstructor + protected static class RequestURI { + private RequestBodyUriSpec spec; + + public static RequestURI requestUri(RequestBodyUriSpec spec) { + return new RequestURI(spec); + } + + public RequestHeaders uri(String uri, Object... objects) { + return requestHeaders(spec.uri(uri, objects)); + } + + public RequestHeaders uri(Function uriBuilder) { + return requestHeaders(spec.uri(uriBuilder)); + } + + } + + @AllArgsConstructor + protected static class RequestHeaders { + private RequestBodySpec spec; + + public static RequestHeaders requestHeaders(RequestBodySpec spec) { + return new RequestHeaders(spec); + } + + public RequestBody headers(Consumer headersConsumer) { + return requestBody(spec.headers(headersConsumer)); + } + + public RequestBody noHeaders() { + return requestBody(spec); + } + } + + @AllArgsConstructor + protected static class RequestBody { + private RequestBodySpec spec; + + public static RequestBody requestBody(RequestBodySpec spec) { + return new RequestBody(spec); + } + + public ResponseStatus jsonBody(Object value) { + return responseStatus(spec.contentType(MediaType.APPLICATION_JSON).bodyValue(value).exchange()); + } + + public ResponseStatus noBody() { + return responseStatus(spec.exchange()); + } + + } + + @AllArgsConstructor + protected static class ResponseStatus { + private ResponseSpec spec; + + public static ResponseStatus responseStatus(ResponseSpec spec) { + return new ResponseStatus(spec); + } + + public ResponseBody status(HttpStatus status) { + return responseBody(spec.expectStatus().isEqualTo(status)); + } + + public ResponseBody ok() { + return status(OK); + } + + public ResponseBody unauthorized() { + return status(UNAUTHORIZED); + } + + public ResponseBody badRequest() { + return status(BAD_REQUEST); + } + + public ResponseBody forbidden() { + return status(FORBIDDEN); + } + + public ResponseBody notFound() { + return status(NOT_FOUND); + } + + } + + @AllArgsConstructor + protected static class ResponseBody { + private ResponseSpec spec; + + public static ResponseBody responseBody(ResponseSpec spec) { + return new ResponseBody(spec); + } + + public T body(Class bodyClass) { + EntityExchangeResult result = spec.expectBody(bodyClass).returnResult(); + return requireNonNull(log(result).getResponseBody()); + } + + public List listBody(Class bodyClass) { + EntityExchangeResult> result = spec.expectBodyList(bodyClass).returnResult(); + return requireNonNull(log(result).getResponseBody()); + } + + public void emptyBody() { + EntityExchangeResult result = spec.expectBody().isEmpty(); + log(result); + } + } + + @RequiredArgsConstructor @Setter @Accessors(fluent = true) + protected class AuthCall { + private final C call; + + public C as(ToAuth auth) { + assertThat(authHeaders).isNull(); + authHeaders = auth.toAuth().authHeaders(); + return call; + } + + public C anonymous() { + assertThat(authHeaders).isNull(); + authHeaders = (HttpHeaders headers) -> {}; + return call; + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/APICalls.java b/src/test/java/eu/bitfield/recipes/test/api/APICalls.java new file mode 100644 index 0000000..06f539f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/APICalls.java @@ -0,0 +1,35 @@ +package eu.bitfield.recipes.test.api; + +import eu.bitfield.recipes.core.account.ToAccountIn; +import eu.bitfield.recipes.test.api.account.RegisterAccountCall; +import eu.bitfield.recipes.test.api.recipe.*; +import eu.bitfield.recipes.view.recipe.ToRecipeViewIn; +import org.springframework.test.web.reactive.server.WebTestClient; + +public interface APICalls { + WebTestClient client(); + + default RegisterAccountCall registerAccountCall(ToAccountIn accountIn) { + return new RegisterAccountCall(client()).accountIn(accountIn); + } + + default AddRecipeCall addRecipeCall(ToRecipeViewIn viewIn) { + return new AddRecipeCall(client()).viewIn(viewIn); + } + + default GetRecipeCall getRecipeCall(long recipeId) { + return new GetRecipeCall(client()).recipeId(recipeId); + } + + default RemoveRecipeCall removeRecipeCall(long recipeId) { + return new RemoveRecipeCall(client()).recipeId(recipeId); + } + + default UpdateRecipeCall updateRecipeCall(long recipeId, ToRecipeViewIn viewIn) { + return new UpdateRecipeCall(client()).recipeId(recipeId).viewIn(viewIn); + } + + default FindRecipesCall findRecipesCall() { + return new FindRecipesCall(client()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/account/RegisterAccountCall.java b/src/test/java/eu/bitfield/recipes/test/api/account/RegisterAccountCall.java new file mode 100644 index 0000000..01eddbd --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/account/RegisterAccountCall.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.test.api.account; + +import eu.bitfield.recipes.core.account.ToAccountIn; +import eu.bitfield.recipes.test.api.APICall; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class RegisterAccountCall extends APICall { + private final WebTestClient client; + private ToAccountIn accountIn = null; + + private ResponseStatus response() { + return request(client).post().uri("/api/account/register") + .noHeaders() + .jsonBody(accountIn.toAccountIn()); + } + + public void ok() { + response().ok().emptyBody(); + } + + public void badRequest() { + response().badRequest().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/AddRecipeCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/AddRecipeCall.java new file mode 100644 index 0000000..397415b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/AddRecipeCall.java @@ -0,0 +1,36 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import eu.bitfield.recipes.view.recipe.ToRecipeViewIn; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class AddRecipeCall extends APICall { + private final WebTestClient client; + private final @Delegate AuthCall auth = new AuthCall<>(this); + private ToRecipeViewIn viewIn = null; + + private ResponseStatus response() { + return request(client).post().uri("/api/recipe/new") + .headers(authHeaders()) + .jsonBody(viewIn.toRecipeViewIn()); + } + + public RecipeViewOut ok() { + return response().ok().body(RecipeViewOut.class); + } + + public ProblemDetail badRequest() { + return response().badRequest().body(ProblemDetail.class); + } + + public void unauthorized() { + response().unauthorized().emptyBody(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/FindRecipesCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/FindRecipesCall.java new file mode 100644 index 0000000..8080ecc --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/FindRecipesCall.java @@ -0,0 +1,49 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import eu.bitfield.recipes.util.Pagination; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.util.UriBuilder; + +import java.util.List; + +import static java.util.Objects.*; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class FindRecipesCall extends APICall { + private final WebTestClient client; + private String category = null; + private String recipe = null; + private Long limit = null; + private Long offset = null; + + public FindRecipesCall pagination(Pagination pagination) { + return limit(pagination.limit()).offset(pagination.offset()); + } + + private ResponseStatus response() { + return request(client).get().uri((UriBuilder uri) -> { + uri.path("/api/recipe/search") + .queryParam("limit", requireNonNull(limit)) + .queryParam("offset", requireNonNull(offset)); + if (recipe != null) uri.queryParam("recipe", recipe); + if (category != null) uri.queryParam("category", category); + return uri.build(); + }) + .noHeaders() + .noBody(); + } + + public List ok() { + return response().ok().listBody(RecipeViewOut.class); + } + + public ProblemDetail badRequest() { + return response().badRequest().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/GetRecipeCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/GetRecipeCall.java new file mode 100644 index 0000000..e96cf55 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/GetRecipeCall.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class GetRecipeCall extends APICall { + private final WebTestClient client; + private Long recipeId = null; + + private ResponseStatus response() { + return request(client).get().uri("/api/recipe/{id}", recipeId) + .noHeaders() + .noBody(); + } + + public RecipeViewOut ok() { + return response().ok().body(RecipeViewOut.class); + } + + public ProblemDetail notFound() { + return response().notFound().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/RemoveRecipeCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/RemoveRecipeCall.java new file mode 100644 index 0000000..d5bf518 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/RemoveRecipeCall.java @@ -0,0 +1,38 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class RemoveRecipeCall extends APICall { + private final WebTestClient client; + private final @Delegate AuthCall auth = new AuthCall<>(this); + private Long recipeId = null; + + private ResponseStatus response() { + return request(client).delete().uri("/api/recipe/{id}", recipeId) + .headers(authHeaders()) + .noBody(); + } + + public void ok() { + response().ok().emptyBody(); + } + + public void unauthorized() { + response().unauthorized().emptyBody(); + } + + public ProblemDetail forbidden() { + return response().forbidden().body(ProblemDetail.class); + } + + public ProblemDetail notFound() { + return response().notFound().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/api/recipe/UpdateRecipeCall.java b/src/test/java/eu/bitfield/recipes/test/api/recipe/UpdateRecipeCall.java new file mode 100644 index 0000000..6c6b27b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/api/recipe/UpdateRecipeCall.java @@ -0,0 +1,45 @@ +package eu.bitfield.recipes.test.api.recipe; + +import eu.bitfield.recipes.test.api.APICall; +import eu.bitfield.recipes.view.recipe.RecipeViewOut; +import eu.bitfield.recipes.view.recipe.ToRecipeViewIn; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +@RequiredArgsConstructor @Setter @Accessors(fluent = true) +public class UpdateRecipeCall extends APICall { + private final WebTestClient client; + private final @Delegate AuthCall auth = new AuthCall<>(this); + private Long recipeId = null; + private @Setter ToRecipeViewIn viewIn = null; + + private ResponseStatus response() { + return request(client).put().uri("/api/recipe/{id}", recipeId) + .headers(authHeaders()) + .jsonBody(viewIn.toRecipeViewIn()); + } + + public RecipeViewOut ok() { + return response().ok().body(RecipeViewOut.class); + } + + public ProblemDetail notFound() { + return response().notFound().body(ProblemDetail.class); + } + + public void unauthorized() { + response().unauthorized().emptyBody(); + } + + public ProblemDetail forbidden() { + return response().forbidden().body(ProblemDetail.class); + } + + public ProblemDetail badRequest() { + return response().badRequest().body(ProblemDetail.class); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/auth/Auth.java b/src/test/java/eu/bitfield/recipes/test/auth/Auth.java new file mode 100644 index 0000000..1161760 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/auth/Auth.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.test.auth; + +import org.springframework.http.HttpHeaders; + +import java.util.function.Consumer; + +public sealed interface Auth extends ToAuth permits BasicAuth { + Consumer authHeaders(); + + default Auth toAuth() { + return this; + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/auth/BasicAuth.java b/src/test/java/eu/bitfield/recipes/test/auth/BasicAuth.java new file mode 100644 index 0000000..0c537ae --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/auth/BasicAuth.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.test.auth; + +import org.springframework.http.HttpHeaders; + +import java.util.function.Consumer; + +public record BasicAuth(String username, String password) implements Auth { + public Consumer authHeaders() { + return (HttpHeaders headers) -> headers.setBasicAuth(username, password); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/auth/ToAuth.java b/src/test/java/eu/bitfield/recipes/test/auth/ToAuth.java new file mode 100644 index 0000000..2ed726f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/auth/ToAuth.java @@ -0,0 +1,6 @@ +package eu.bitfield.recipes.test.auth; + +public interface ToAuth { + Auth toAuth(); +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountActions.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountActions.java new file mode 100644 index 0000000..549b714 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountActions.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.Context; + +import java.util.Collection; + +public interface AccountActions { + Context accountContext(); + + AccountTemplate accountTemplate(ProfileTag profileTag); + + Collection accountTemplates(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountLayer.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountLayer.java new file mode 100644 index 0000000..015053c --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountLayer.java @@ -0,0 +1,41 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.password.Password; +import eu.bitfield.recipes.auth.password.PasswordIn; +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.Context; +import eu.bitfield.recipes.test.data.SlotInitializer; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Collection; + +import static lombok.AccessLevel.*; + +@RequiredArgsConstructor @Accessors(fluent = true) +public class AccountLayer implements AccountActions, SlotInitializer { + private final AccountTemplates templates = new AccountTemplates(); + private final @Getter(PROTECTED) PasswordEncoder encoder = new BCryptPasswordEncoder(4); + private final Context context = new Context<>(); + + public Context accountContext() { + return context; + } + + public AccountTemplate accountTemplate(ProfileTag profileTag) { + return templates.get(profileTag); + } + + public Collection accountTemplates() { + return templates.all(); + } + + + public void init(AccountSlot slot) { + Password password = Password.of((PasswordIn passwordIn) -> encoder.encode(passwordIn.raw()), slot.passwordIn()); + slot.init(password); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountMask.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountMask.java new file mode 100644 index 0000000..bd227e1 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountMask.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.auth.password.PasswordIn; +import eu.bitfield.recipes.core.account.AccountIn; +import eu.bitfield.recipes.core.account.ToAccountIn; +import eu.bitfield.recipes.test.core.profile.ProfileTag; + + +public interface AccountMask extends ToAccountIn { + ProfileTag profileTag(); + + EmailAddressIn emailIn(); + + default EmailAddress email() { + return EmailAddress.of(emailIn()); + } + + PasswordIn passwordIn(); + + default AccountIn toAccountIn() { + return new AccountIn(emailIn(), passwordIn()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountQueries.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountQueries.java new file mode 100644 index 0000000..a44c80d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountQueries.java @@ -0,0 +1,73 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddress; +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.test.core.profile.ProfileQueries; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.*; + +public interface AccountQueries extends ProfileQueries, AccountActions { + EmailAddress unusedEmail = AccountQueries.emailAddress("unused@example.com"); + + static EmailAddress emailAddress(String address) { + EmailAddressIn emailIn = EmailAddressIn.of(address); + assertThat(emailIn).isNotNull(); + return EmailAddress.of(emailIn); + } + + default AccountSlotBuilder accountSlot() { + return new AccountSlotBuilder(this); + } + + default AccountSlotBuilder accountSlot(AccountTemplate template) { + return accountSlot().template(template); + } + + default AccountSlotBuilder accountSlot(ProfileTag profileTag) { + return accountSlot(accountTemplate(profileTag)); + } + + default Stream accountSlots() { + return accountTemplates().stream().map(this::accountSlot); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class AccountSlotBuilder implements SlotBuilder { + private final @Delegate AccountQueries queries; + private @Getter @Setter @Nullable AccountTemplate template = null; + private @Getter @Setter @Nullable ProfileSlot profile = null; + + public void resolve() { + if (template != null) { + if (profile != null && profile.tag() != template.profileTag()) { + throw new IllegalArgumentException("Profile tag mismatch"); + } + + if (profile == null) { + profile = profileSlot(template.profileTag()).blank(); + } + return; + } + profile = Optional.ofNullable(profile).orElseGet(() -> profileSlot().blank()); + template = Optional.ofNullable(template).orElseGet(() -> accountTemplate(profile.tag())); + } + + public AccountSlot blank() { + resolve(); + return accountContext().add(new AccountSlot(template, profile)); + } + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountSlot.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountSlot.java new file mode 100644 index 0000000..224bd13 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountSlot.java @@ -0,0 +1,60 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.password.Password; +import eu.bitfield.recipes.core.account.Account; +import eu.bitfield.recipes.test.auth.Auth; +import eu.bitfield.recipes.test.auth.BasicAuth; +import eu.bitfield.recipes.test.auth.ToAuth; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class AccountSlot implements Comparable, EntitySlot, AccountMask, ToAuth { + public static EntityToken accountToken = new EntityToken<>(3, AccountSlot.class); + public static Comparator order = comparing(AccountSlot::template).thenComparing(AccountSlot::profile); + private final @Delegate AccountTemplate template; + private final ProfileSlot profile; + private transient Password password; + private transient Account initial; + private transient Account saved; + + public int compareTo(@Nullable AccountSlot other) { + return order.compare(this, other); + } + + public void init(Password password) { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + this.password = password; + this.initial = Account.initial(profile.id(), email(), password); + } + + public AnyEntityToken entityToken() { + return accountToken; + } + + public void save(Account saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(profile); + } + + public Auth toAuth() { + return new BasicAuth(emailIn().address(), passwordIn().raw()); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplate.java new file mode 100644 index 0000000..a2ad923 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplate.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.auth.password.PasswordIn; +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.Template; +import lombok.With; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +@With +public record AccountTemplate(ProfileTag profileTag, EmailAddressIn emailIn, PasswordIn passwordIn) + implements Comparable, AccountMask, Template { + public static Comparator order = + comparing(AccountTemplate::profileTag) + .thenComparing(AccountTemplate::emailIn) + .thenComparing(AccountTemplate::passwordIn); + + public int compareTo(@Nullable AccountTemplate other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplates.java new file mode 100644 index 0000000..81575a0 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/account/AccountTemplates.java @@ -0,0 +1,24 @@ +package eu.bitfield.recipes.test.core.account; + +import eu.bitfield.recipes.auth.email.EmailAddressIn; +import eu.bitfield.recipes.auth.password.PasswordIn; +import eu.bitfield.recipes.test.core.profile.ProfileTag; +import eu.bitfield.recipes.test.data.TemplateContainer; + +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; + + +public class AccountTemplates extends TemplateContainer { + public AccountTemplates() { + add(profiles.ada, "ada"); + add(profiles.bea, "bea"); + add(profiles.rio, "rio"); + } + + private void add(ProfileTag profileTag, String name) { + EmailAddressIn emailIn = EmailAddressIn.ofUnchecked(name + "@example.com"); + PasswordIn passwordIn = PasswordIn.ofUnchecked("MyName" + name.toUpperCase() + "IsNotAPassword"); + + add(profileTag, new AccountTemplate(profileTag, emailIn, passwordIn)); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryActions.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryActions.java new file mode 100644 index 0000000..1a2b8cf --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryActions.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Context; + +import java.util.Collection; + +public interface CategoryActions { + Context categoryContext(); + + Collection categoryTemplates(); + + CategoryTemplate categoryTemplate(CategoryTag tag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryLayer.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryLayer.java new file mode 100644 index 0000000..31ca87f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryLayer.java @@ -0,0 +1,25 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; + +import java.util.Collection; + +@RequiredArgsConstructor +public class CategoryLayer implements CategoryActions { + private final CategoryTemplates templates = new CategoryTemplates(); + private final Context context = new Context<>(); + + public Context categoryContext() { + return context; + } + + public Collection categoryTemplates() { + return templates.all(); + } + + public CategoryTemplate categoryTemplate(CategoryTag tag) { + return templates.get(tag); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryMask.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryMask.java new file mode 100644 index 0000000..56207a4 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryMask.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.core.category.CategoryIn; +import eu.bitfield.recipes.core.category.ToCategoryIn; + +public interface CategoryMask extends ToCategoryIn { + CategoryTag tag(); + + String name(); + + default CategoryIn toCategoryIn() { + return new CategoryIn(name()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryQueries.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryQueries.java new file mode 100644 index 0000000..327c5b2 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryQueries.java @@ -0,0 +1,56 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.core.category.ToCategoryOut; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.core.category.CategoryTags.*; + +public interface CategoryQueries extends CategoryActions { + default CategorySlotBuilder categorySlot() { + return new CategorySlotBuilder(this); + } + + default CategorySlotBuilder categorySlot(CategoryTemplate template) { + return categorySlot().template(template); + } + + default CategorySlotBuilder categorySlot(CategoryTag tag) { + return categorySlot(categoryTemplate(tag)); + } + + default Stream categorySlots() { + return categoryTemplates().stream().map(this::categorySlot); + } + + default Predicate hasCategoryName(String name) { + return value -> value.toCategoryOut().name().equalsIgnoreCase(name); + } + + default void invalidate(CategorySlot slot) { + slot.init(); + slot.save(slot.initial()); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class CategorySlotBuilder implements SlotBuilder { + private final @Delegate CategoryQueries queries; + private @Getter @Setter @Nullable CategoryTemplate template = null; + + public CategorySlot blank() { + template = Optional.ofNullable(template).orElseGet(() -> categoryTemplate(categories.soup)); + return categoryContext().add(new CategorySlot(template)); + } + } +} + + diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategorySlot.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategorySlot.java new file mode 100644 index 0000000..da827ec --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategorySlot.java @@ -0,0 +1,48 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.category.ToCategoryOut; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class CategorySlot implements Comparable, EntitySlot, CategoryMask, ToCategoryOut { + public static EntityToken categoryToken = new EntityToken<>(2, CategorySlot.class); + public static Comparator order = comparing(CategorySlot::template); + private final @Delegate CategoryTemplate template; + private transient Category initial; + private transient @Delegate(types = ToCategoryOut.class) Category saved; + + public int compareTo(@Nullable CategorySlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + initial + "was initialized twice"); + initial = Category.initial(name()); + } + + public AnyEntityToken entityToken() { + return categoryToken; + } + + public void save(Category saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.empty(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTag.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTag.java new file mode 100644 index 0000000..e192740 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTag.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Tag; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record CategoryTag(int value) implements Tag, Comparable { + public static Comparator order = comparing(CategoryTag::value); + + public int compareTo(@Nullable CategoryTag other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTags.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTags.java new file mode 100644 index 0000000..5010d58 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTags.java @@ -0,0 +1,12 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Tags; + +public class CategoryTags extends Tags { + public static final CategoryTags categories = new CategoryTags(); + public final CategoryTag soup = tag(), dip = tag(), salad = tag(), pasta = tag(), baked = tag(); + + protected CategoryTag tag(int value) { + return new CategoryTag(value); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplate.java new file mode 100644 index 0000000..b9f69eb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplate.java @@ -0,0 +1,19 @@ +package eu.bitfield.recipes.test.core.category; + +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record CategoryTemplate(CategoryTag tag, String name) + implements Comparable, CategoryMask, Template { + public static Comparator order = + comparing(CategoryTemplate::tag).thenComparing(CategoryTemplate::name); + + public int compareTo(@Nullable CategoryTemplate other) { + return order.compare(this, other); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplates.java new file mode 100644 index 0000000..f5ccc29 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/category/CategoryTemplates.java @@ -0,0 +1,19 @@ +package eu.bitfield.recipes.test.core.category; + + +import eu.bitfield.recipes.test.data.TemplateContainer; + +import static eu.bitfield.recipes.test.core.category.CategoryTags.categories; + +public class CategoryTemplates extends TemplateContainer { + public CategoryTemplates() { + add(categories.dip, "Dip"); + add(categories.soup, "Soup"); + add(categories.salad, "Salad"); + add(categories.pasta, "Pasta"); + add(categories.baked, "Baked"); + } + + void add(CategoryTag tag, String name) {add(tag, new CategoryTemplate(tag, name));} +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientActions.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientActions.java new file mode 100644 index 0000000..9b28a35 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientActions.java @@ -0,0 +1,17 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +public interface IngredientActions { + Context ingredientContext(); + + MultiValueMap ingredientTemplatesByRecipe(); + + IngredientTemplate ingredientTemplate(RecipeTag recipeTag, long index); + + List ingredientTemplates(RecipeTag recipeTag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientLayer.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientLayer.java new file mode 100644 index 0000000..57d8908 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientLayer.java @@ -0,0 +1,32 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +import static java.util.Collections.*; + +@RequiredArgsConstructor +public class IngredientLayer implements IngredientActions { + private final IngredientTemplates templates = new IngredientTemplates(); + private final Context context = new Context<>(); + + public Context ingredientContext() { + return context; + } + + public MultiValueMap ingredientTemplatesByRecipe() { + return templates.byRecipe(); + } + + public IngredientTemplate ingredientTemplate(RecipeTag recipeTag, long index) { + return templates.get(new IngredientTemplates.Key(recipeTag, index)); + } + + public List ingredientTemplates(RecipeTag recipeTag) { + return ingredientTemplatesByRecipe().getOrDefault(recipeTag, emptyList()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientMask.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientMask.java new file mode 100644 index 0000000..6f52a44 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientMask.java @@ -0,0 +1,17 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.core.ingredient.IngredientIn; +import eu.bitfield.recipes.core.ingredient.ToIngredientIn; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; + +public interface IngredientMask extends ToIngredientIn { + RecipeTag recipeTag(); + + long index(); + + String name(); + + default IngredientIn toIngredientIn() { + return new IngredientIn(name()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientQueries.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientQueries.java new file mode 100644 index 0000000..e49f229 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientQueries.java @@ -0,0 +1,103 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeQueries; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Comparator.*; +import static java.util.stream.Collectors.*; + +public interface IngredientQueries extends RecipeQueries, IngredientActions { + default IngredientSlotBuilder ingredientSlot() { + return new IngredientSlotBuilder(this); + } + + default IngredientSlotBuilder ingredientSlot(IngredientTemplate template) { + return ingredientSlot().template(template); + } + + default IngredientSlotBuilder ingredientSlot(RecipeTag recipeTag, long index) { + return ingredientSlot(ingredientTemplate(recipeTag, index)); + } + + default Stream ingredientSlots(RecipeTag recipe) { + return ingredientTemplates(recipe).stream().map(this::ingredientSlot); + } + + default Stream ingredientSlots(RecipeSlot recipe) { + return ingredientSlots(recipe.tag()); + } + + default List recipeIngredientGroupsWithIngredientCounts(int... ingredientCounts) { + List recipeTagsInDescIngredientCountOrder = recipes.all() + .stream() + .sorted(comparing((RecipeTag recipeTag) -> ingredientTemplates(recipeTag).size())) + .collect(toCollection(ArrayList::new)); + List sortedIngredientCounts = IntStream.of(ingredientCounts).boxed().sorted(reverseOrder()).toList(); + MultiValueMap recipeTagsByIngredientCount = multiValueMap(); + for (int ingredientCount : sortedIngredientCounts) { + RecipeTag tag = recipeTagsInDescIngredientCountOrder.removeLast(); + recipeTagsByIngredientCount.add(ingredientCount, tag); + } + List groups = new ArrayList<>(); + for (int ingredientCount : ingredientCounts) { + RecipeTag recipeTag = recipeTagsByIngredientCount.get(ingredientCount).removeLast(); + groups.add(new RecipeIngredientGroup(recipeSlot(recipeTag), + ingredientSlots(recipeTag).limit(ingredientCount).toList())); + } + return groups; + } + + default RecipeIngredientGroup recipeIngredientGroupWithIngredientCount(int ingredientCount) { + return recipeIngredientGroupsWithIngredientCounts(ingredientCount).getFirst(); + } + + record RecipeIngredientGroup(RecipeSlotBuilder recipe, List ingredientList) { + public Stream ingredients() { + return ingredientList.stream(); + } + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class IngredientSlotBuilder implements SlotBuilder { + private final @Delegate IngredientQueries queries; + private @Getter @Setter @Nullable IngredientTemplate template = null; + private @Getter @Setter @Nullable RecipeSlot recipe = null; + + private void resolve() { + if (template != null) { + if (recipe != null && recipe.tag() != template.recipeTag()) { + throw new IllegalArgumentException("Recipe tag mismatch"); + } + + if (recipe == null) { + recipe = recipeSlot().template(recipeTemplate(template.recipeTag())).blank(); + } + return; + } + recipe = Optional.ofNullable(recipe).orElseGet(() -> recipeSlot().blank()); + template = Optional.ofNullable(template).orElseGet(() -> ingredientTemplate(recipe.tag(), 0)); + } + + public IngredientSlot blank() { + resolve(); + return ingredientContext().add(new IngredientSlot(template, recipe)); + } + } +} \ No newline at end of file diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientSlot.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientSlot.java new file mode 100644 index 0000000..bac820d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientSlot.java @@ -0,0 +1,52 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.ingredient.ToIngredientOut; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class IngredientSlot + implements Comparable, EntitySlot, IngredientMask, ToIngredientOut { + public static EntityToken ingredientToken = new EntityToken<>(5, IngredientSlot.class); + public static Comparator order = + comparing(IngredientSlot::template).thenComparing(IngredientSlot::recipe); + private final @Delegate IngredientTemplate template; + private final RecipeSlot recipe; + private transient Ingredient initial; + private transient @Delegate(types = ToIngredientOut.class) Ingredient saved; + + public int compareTo(@Nullable IngredientSlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = Ingredient.initial(recipe.id(), name()); + } + + public AnyEntityToken entityToken() { + return ingredientToken; + } + + public void save(Ingredient saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(recipe); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplate.java new file mode 100644 index 0000000..1687793 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplate.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record IngredientTemplate(RecipeTag recipeTag, long index, String name) + implements Comparable, IngredientMask, Template { + public static Comparator order = + comparing(IngredientTemplate::recipeTag) + .thenComparing(IngredientTemplate::index) + .thenComparing(IngredientTemplate::name); + + public int compareTo(@Nullable IngredientTemplate other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplates.java new file mode 100644 index 0000000..429482e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/ingredient/IngredientTemplates.java @@ -0,0 +1,60 @@ +package eu.bitfield.recipes.test.core.ingredient; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.TemplateContainer; +import org.springframework.util.MultiValueMap; + +import java.util.ArrayList; +import java.util.List; + +import static eu.bitfield.recipes.test.core.ingredient.IngredientTemplates.Key.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Collections.*; +import static org.springframework.util.CollectionUtils.*; + +public class IngredientTemplates extends TemplateContainer { + private final MultiValueMap byRecipe = multiValueMap(); + + public IngredientTemplates() { + add(recipes.minestrone, + names("Onions", "Carrots", "Celery", "Garlic", "Tomatoes", "Beans", "Pasta", "Kale", "Vegetables", + "Oregano", "Basil")); + add(recipes.hummus, names("Chickpeas", "Tahini", "Lemon juice", "Garlic")); + add(recipes.muhammara, + names("Pomegranate molasses", "Red bell peppers", "Bread crumbs", "Cumin", "Chili flakes", "Garlic", + "Walnuts", "Lemon juice")); + add(recipes.tomatoSalad, names("Tomatoes", "Onions", "Balsamico Vinegar")); + } + + private String[] names(String... names) {return names;} + + private void add(RecipeTag recipeTag, String[] names) { + long index = 0; + List templates = new ArrayList<>(names.length); + for (String name : names) { + var template = new IngredientTemplate(recipeTag, index, name); + add(key(template), template); + templates.add(template); + index++; + } + byRecipe.addAll(recipeTag, templates); + } + + public List byRecipe(RecipeTag recipeTag) { + return unmodifiableList(byRecipe.getOrDefault(recipeTag, emptyList())); + } + + public MultiValueMap byRecipe() { + return unmodifiableMultiValueMap(byRecipe); + } + + public record Key( + RecipeTag recipeTag, + long index + ) { + static Key key(IngredientTemplate template) { + return new Key(template.recipeTag(), template.index()); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/CategoryLinkGroup.java b/src/test/java/eu/bitfield/recipes/test/core/link/CategoryLinkGroup.java new file mode 100644 index 0000000..27d69a3 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/CategoryLinkGroup.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; + +import java.util.List; + +public record CategoryLinkGroup(CategoryTag categoryTag, List linkTemplates) { + public int linkCount() { + return linkTemplates.size(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatActions.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatActions.java new file mode 100644 index 0000000..6a452b5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatActions.java @@ -0,0 +1,20 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; + +import java.util.Collection; +import java.util.List; + +public interface LinkRecCatActions { + Context linkRecCatContext(); + + Collection linkTemplates(); + + LinkRecCatTemplate linkTemplate(RecipeTag recipeTag, CategoryTag categoryTag); + + List linkTemplates(RecipeTag recipeTag); + + List linkTemplates(CategoryTag categoryTag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatLayer.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatLayer.java new file mode 100644 index 0000000..5954930 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatLayer.java @@ -0,0 +1,31 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +public class LinkRecCatLayer implements LinkRecCatActions { + private final LinkRecCatTemplates templates = new LinkRecCatTemplates(); + private final Context context = new Context<>(); + + public Context linkRecCatContext() {return context;} + + public Collection linkTemplates() {return templates.all();} + + public LinkRecCatTemplate linkTemplate(RecipeTag recipeTag, CategoryTag categoryTag) { + return templates.get(recipeTag, categoryTag); + } + + public List linkTemplates(RecipeTag recipeTag) { + return templates.linkTemplatesByRecipeTag(recipeTag); + } + + public List linkTemplates(CategoryTag categoryTag) { + return templates.linkTemplatesByCategoryTag(categoryTag); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatMask.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatMask.java new file mode 100644 index 0000000..7d12ad7 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatMask.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; + +public interface LinkRecCatMask { + RecipeTag recipeTag(); + + CategoryTag categoryTag(); + + default Key key() { + return new Key(recipeTag(), categoryTag()); + } + + record Key(RecipeTag recipeTag, CategoryTag categoryTag) {} +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatQueries.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatQueries.java new file mode 100644 index 0000000..d707d4e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatQueries.java @@ -0,0 +1,156 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryQueries; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeQueries; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.stream.Stream; + +import static java.util.function.Function.*; +import static java.util.stream.Collectors.*; + +public interface LinkRecCatQueries extends LinkRecCatActions, CategoryQueries, RecipeQueries { + default LinkRecCatSlotBuilder linkRecCatSlot() { + return new LinkRecCatSlotBuilder(this); + } + + default LinkRecCatSlotBuilder linkRecCatSlot(RecipeTag recipeTag, CategoryTag categoryTag) { + return linkRecCatSlot(linkTemplate(recipeTag, categoryTag)); + } + + default LinkRecCatSlotBuilder linkRecCatSlot(LinkRecCatTemplate template) { + return linkRecCatSlot().template(template); + } + + default Stream linkRecCatSlots() { + return linkTemplates().stream().map(this::linkRecCatSlot); + } + + default Map> recipesByLinkCount() { + return recipeTemplates() + .stream() + .collect(toMap(identity(), recipe -> linkTemplates(recipe.tag()).size())) + .entrySet() + .stream() + .collect(groupingBy(Entry::getValue, mapping(entry -> recipeSlot(entry.getKey()), toList()))); + } + + default Map> categoriesByLinkCount() { + return categoryTemplates() + .stream() + .collect(toMap(identity(), category -> linkTemplates(category.tag()).size())) + .entrySet() + .stream() + .collect(groupingBy(Entry::getValue, mapping(entry -> categorySlot(entry.getKey()), toList()))); + } + + default Stream recipeSlotsWithLinkCounts(int... linkCounts) { + Map> recipesByLinkCount = recipesByLinkCount(); + List recipes = new ArrayList<>(); + for (int linkCount : linkCounts) { + recipes.add(recipesByLinkCount.get(linkCount).removeLast()); + } + return recipes.stream(); + } + + default RecipeSlotBuilder recipeSlotWithLinkCount(int linkCount) { + return recipeSlotsWithLinkCounts(linkCount).findFirst().orElseThrow(); + } + + default Stream categorySlotsWithLinkCounts(int... linkCounts) { + Map> categoriesByLinkCount = categoriesByLinkCount(); + List categories = new ArrayList<>(); + for (int linkCount : linkCounts) { + categories.add(categoriesByLinkCount.get(linkCount).removeLast()); + } + return categories.stream(); + } + + default CategorySlotBuilder categorySlotWithLinkCount(int linkCount) { + return categorySlotsWithLinkCounts(linkCount).findFirst().orElseThrow(); + } + + default Stream linkRecCatSlots(RecipeSlot recipe) { + return linkTemplates().stream() + .filter(template -> template.recipeTag() == recipe.tag()) + .map(template -> linkRecCatSlot(template).recipe(recipe)); + } + + default Stream linkRecCatSlots(CategorySlot category) { + return linkTemplates().stream() + .filter(template -> template.categoryTag() == category.tag()) + .map(template -> linkRecCatSlot(template).category(category)); + } + + default Stream linkedCategorySlots(RecipeTag recipeTag) { + return linkTemplates(recipeTag).stream().map(LinkRecCatTemplate::categoryTag).map(this::categorySlot); + } + + default Stream linkedCategorySlots(RecipeSlot recipe) { + return linkedCategorySlots(recipe.tag()); + } + + default Stream linkedRecipeSlots(CategoryTag categoryTag) { + return linkTemplates(categoryTag).stream().map(LinkRecCatTemplate::recipeTag).map(this::recipeSlot); + } + + default Stream linkedRecipeSlots(CategorySlot category) { + return linkedRecipeSlots(category.tag()); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class LinkRecCatSlotBuilder implements SlotBuilder { + private final @Delegate LinkRecCatQueries queries; + private @Getter @Setter @Nullable LinkRecCatTemplate template = null; + private @Getter @Setter @Nullable RecipeSlot recipe = null; + private @Getter @Setter @Nullable CategorySlot category = null; + + private void resolve() { + if (template != null) { + if (category != null && category.tag() != template.categoryTag()) { + throw new IllegalArgumentException("Category tag mismatch"); + } + if (recipe != null && recipe.tag() != template.recipeTag()) { + throw new IllegalArgumentException("Recipe tag mismatch"); + } + + if (category == null) { + category = categorySlot().template(categoryTemplate(template.categoryTag())).blank(); + } + if (recipe == null) { + recipe = recipeSlot().template(recipeTemplate(template.recipeTag())).blank(); + } + return; + } + recipe = Optional.ofNullable(recipe).orElseGet(() -> recipeSlot().blank()); + category = Optional.ofNullable(category).orElseGet(() -> categorySlot().blank()); + template = Optional.ofNullable(template).orElseGet(() -> { + assert recipe != null && category != null; + return linkTemplate(recipe.tag(), category.tag()); + }); + + } + + public LinkRecCatSlot blank() { + resolve(); + return linkRecCatContext().add(new LinkRecCatSlot(template, recipe, category)); + } + } +} + + diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatSlot.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatSlot.java new file mode 100644 index 0000000..d6b880b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatSlot.java @@ -0,0 +1,54 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class LinkRecCatSlot implements Comparable, EntitySlot, LinkRecCatMask { + public static final EntityToken linkRecCatToken = new EntityToken<>(7, LinkRecCatSlot.class); + public static Comparator order = + comparing(LinkRecCatSlot::template) + .thenComparing(LinkRecCatSlot::recipe) + .thenComparing(LinkRecCatSlot::category); + private final @Delegate LinkRecCatTemplate template; + private final RecipeSlot recipe; + private final CategorySlot category; + private transient LinkRecCat initial; + private transient LinkRecCat saved; + + public int compareTo(@Nullable LinkRecCatSlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = LinkRecCat.initial(recipe.id(), category.id()); + } + + public AnyEntityToken entityToken() { + return linkRecCatToken; + } + + public void save(LinkRecCat saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(recipe, category); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplate.java new file mode 100644 index 0000000..4510e5c --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplate.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record LinkRecCatTemplate(RecipeTag recipeTag, CategoryTag categoryTag) + implements Comparable, Template, LinkRecCatMask { + public static Comparator order = + comparing(LinkRecCatTemplate::recipeTag).thenComparing(LinkRecCatTemplate::categoryTag); + + public int compareTo(@Nullable LinkRecCatTemplate other) { + return order.compare(this, other); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplates.java new file mode 100644 index 0000000..55553d0 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/link/LinkRecCatTemplates.java @@ -0,0 +1,57 @@ +package eu.bitfield.recipes.test.core.link; + +import eu.bitfield.recipes.test.core.category.CategoryTag; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.TemplateContainer; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +import static eu.bitfield.recipes.test.core.category.CategoryTags.*; +import static eu.bitfield.recipes.test.core.link.LinkRecCatTemplates.Key.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Collections.*; + +public class LinkRecCatTemplates + extends TemplateContainer { + private final MultiValueMap templatesByRecipeTag = multiValueMap(); + private final MultiValueMap templatesByCategoryTag = multiValueMap(); + + public LinkRecCatTemplates() { + add(recipes.minestrone, categories.soup, categories.pasta); + add(recipes.hummus, categories.dip); + add(recipes.muhammara, categories.dip); + add(recipes.tomatoSalad, categories.salad); + } + + private void add(RecipeTag recipeTag, CategoryTag... categoryTags) { + for (CategoryTag categoryTag : categoryTags) { + var template = new LinkRecCatTemplate(recipeTag, categoryTag); + add(key(template), template); + templatesByRecipeTag.add(recipeTag, template); + templatesByCategoryTag.add(categoryTag, template); + } + } + + public List linkTemplatesByRecipeTag(RecipeTag recipeTag) { + return unmodifiableList(templatesByRecipeTag.getOrDefault(recipeTag, emptyList())); + } + + public List linkTemplatesByCategoryTag(CategoryTag categoryTag) { + return unmodifiableList(templatesByCategoryTag.getOrDefault(categoryTag, emptyList())); + } + + public LinkRecCatTemplate get(RecipeTag recipeTag, CategoryTag categoryTag) { + return get(new Key(recipeTag, categoryTag)); + } + + public record Key( + RecipeTag recipeTag, + CategoryTag categoryTag + ) { + static Key key(LinkRecCatTemplate template) { + return new Key(template.recipeTag(), template.categoryTag()); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileActions.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileActions.java new file mode 100644 index 0000000..c9a08fb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileActions.java @@ -0,0 +1,9 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.Context; + +public interface ProfileActions { + Context profileContext(); + + ProfileTemplate profileTemplate(ProfileTag profileTag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileLayer.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileLayer.java new file mode 100644 index 0000000..a7c101b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileLayer.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ProfileLayer implements ProfileActions { + private final ProfileTemplates templates = new ProfileTemplates(); + private final Context context = new Context<>(); + + public Context profileContext() { + return context; + } + + public ProfileTemplate profileTemplate(ProfileTag profileTag) { + return templates.get(profileTag); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileMask.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileMask.java new file mode 100644 index 0000000..000e23e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileMask.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.core.profile; + +public interface ProfileMask { + ProfileTag tag(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileQueries.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileQueries.java new file mode 100644 index 0000000..ff57669 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileQueries.java @@ -0,0 +1,47 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Optional; + +import static eu.bitfield.recipes.test.core.profile.ProfileTags.*; + +public interface ProfileQueries extends ProfileActions { + default ProfileSlotBuilder profileSlot() { + return new ProfileSlotBuilder(this); + } + + default ProfileSlotBuilder profileSlot(ProfileTemplate template) { + return profileSlot().template(template); + } + + default ProfileSlotBuilder profileSlot(ProfileTag tag) { + return profileSlot(profileTemplate(tag)); + } + + default void invalidate(ProfileSlot slot) { + slot.init(); + slot.save(slot.initial()); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class ProfileSlotBuilder implements SlotBuilder { + private final @Delegate ProfileQueries queries; + private @Getter @Setter @Nullable ProfileTemplate template = null; + + public ProfileSlot blank() { + template = Optional.ofNullable(template).orElseGet(() -> profileTemplate(profiles.ada)); + return profileContext().add(new ProfileSlot(template)); + } + } +} + + + + diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileSlot.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileSlot.java new file mode 100644 index 0000000..c9a8811 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileSlot.java @@ -0,0 +1,49 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.core.profile.Profile; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class ProfileSlot implements Comparable, EntitySlot, ProfileMask { + public static EntityToken profileToken = new EntityToken<>(1, ProfileSlot.class); + public static Comparator order = comparing(ProfileSlot::template); + private final @Delegate ProfileTemplate template; + private transient Profile initial; + private transient Profile saved; + + public int compareTo(@Nullable ProfileSlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = Profile.initial(); + } + + public AnyEntityToken entityToken() { + return profileToken; + } + + public void save(Profile saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.empty(); + } +} + + diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTag.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTag.java new file mode 100644 index 0000000..6f8456e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTag.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.Tag; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record ProfileTag(int value) implements Tag, Comparable { + public static Comparator order = comparing(ProfileTag::value); + + public int compareTo(@Nullable ProfileTag other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTags.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTags.java new file mode 100644 index 0000000..01b1604 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTags.java @@ -0,0 +1,12 @@ +package eu.bitfield.recipes.test.core.profile; + +import eu.bitfield.recipes.test.data.Tags; + +public class ProfileTags extends Tags { + public static final ProfileTags profiles = new ProfileTags(); + public final ProfileTag ada = tag(), bea = tag(), rio = tag(); + + protected ProfileTag tag(int value) { + return new ProfileTag(value); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplate.java new file mode 100644 index 0000000..388ea73 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplate.java @@ -0,0 +1,17 @@ +package eu.bitfield.recipes.test.core.profile; + + +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record ProfileTemplate(ProfileTag tag) implements Comparable, ProfileMask, Template { + public static Comparator order = comparing(ProfileTemplate::tag); + + public int compareTo(@Nullable ProfileTemplate other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplates.java new file mode 100644 index 0000000..0c03f2f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/profile/ProfileTemplates.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.test.core.profile; + + +import eu.bitfield.recipes.test.data.TemplateContainer; + +import static eu.bitfield.recipes.test.core.profile.ProfileTags.profiles; + +public class ProfileTemplates extends TemplateContainer { + public ProfileTemplates() { + for (ProfileTag tag : profiles.all()) { + add(tag, new ProfileTemplate(tag)); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeActions.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeActions.java new file mode 100644 index 0000000..3df2007 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeActions.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.test.data.Context; +import eu.bitfield.recipes.util.Chronology; + +import java.util.Collection; + +public interface RecipeActions { + Context recipeContext(); + + Collection recipeTemplates(); + + RecipeTemplate recipeTemplate(RecipeTag recipeTag); + + void init(RecipeSlot slot); + + Chronology realTime(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeLayer.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeLayer.java new file mode 100644 index 0000000..acb665b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeLayer.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.test.data.Context; +import eu.bitfield.recipes.test.data.SlotInitializer; +import eu.bitfield.recipes.util.Chronology; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.Collection; + +@RequiredArgsConstructor @Accessors(fluent = true) +public class RecipeLayer implements RecipeActions, SlotInitializer { + private final RecipeTemplates templates = new RecipeTemplates(); + private final @Getter Chronology realTime = new Chronology(); + private final Context context = new Context<>(); + + public Context recipeContext() { + return context; + } + + public Collection recipeTemplates() {return templates.all();} + + public RecipeTemplate recipeTemplate(RecipeTag recipeTag) {return templates.get(recipeTag);} + + public void init(RecipeSlot slot) { + slot.init(realTime.now()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeMask.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeMask.java new file mode 100644 index 0000000..45e638e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeMask.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeIn; +import eu.bitfield.recipes.core.recipe.ToRecipeIn; + +public interface RecipeMask extends ToRecipeIn { + RecipeTag tag(); + + String name(); + + String description(); + + default RecipeIn toRecipeIn() { + return new RecipeIn(name(), description()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeQueries.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeQueries.java new file mode 100644 index 0000000..3d4b952 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeQueries.java @@ -0,0 +1,71 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.core.recipe.RecipeIn; +import eu.bitfield.recipes.core.recipe.ToRecipeOut; +import eu.bitfield.recipes.test.core.profile.ProfileQueries; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static java.util.Comparator.*; + +public interface RecipeQueries extends RecipeActions, ProfileQueries { + default Predicate containsRecipeName(String recipeName) { + return (T value) -> value.toRecipeOut().name().toLowerCase().contains(recipeName.toLowerCase()); + } + + default Comparator orderByRecipeChangedAtDesc() { + return comparing((T value) -> value.toRecipe().changedAt()).reversed(); + } + + default RecipeSlotBuilder recipeSlot() { + return new RecipeSlotBuilder(this); + } + + default RecipeSlotBuilder recipeSlot(RecipeTemplate template) { + return recipeSlot().template(template); + } + + default RecipeSlotBuilder recipeSlot(RecipeTag tag) { + return recipeSlot(recipeTemplate(tag)); + } + + default Stream recipeSlots() { + return recipeTemplates().stream().map(this::recipeSlot); + } + + default void invalidate(RecipeSlot slot) { + invalidate(slot.author()); + init(slot); + slot.save(slot.initial()); + } + + default RecipeIn invalid(RecipeIn recipeIn) { + return recipeIn.withName(""); + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class RecipeSlotBuilder implements SlotBuilder, RecipeMask { + private final @Delegate RecipeQueries queries; + private @Delegate @Getter @Setter @Nullable RecipeTemplate template = null; + private @Getter @Setter @Nullable ProfileSlot author = null; + + public RecipeSlot blank() { + template = Optional.ofNullable(template).orElseGet(() -> recipeTemplate(recipes.minestrone)); + author = Optional.ofNullable(author).orElseGet(() -> profileSlot().blank()); + return recipeContext().add((new RecipeSlot(template, author))); + } + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeSlot.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeSlot.java new file mode 100644 index 0000000..fd61294 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeSlot.java @@ -0,0 +1,55 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.time.Instant; +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class RecipeSlot implements Comparable, EntitySlot, RecipeMask, ToRecipe { + public static EntityToken recipeToken = new EntityToken<>(4, RecipeSlot.class); + public static Comparator order = comparing(RecipeSlot::template).thenComparing(RecipeSlot::author); + private final @Delegate RecipeTemplate template; + private final ProfileSlot author; + private transient Recipe initial; + private transient Recipe saved; + + public void init(Instant createdAt) { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = Recipe.initial(author.id(), name(), description(), createdAt); + } + + public AnyEntityToken entityToken() { + return recipeToken; + } + + public void save(Recipe saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(author); + } + + public int compareTo(@Nullable RecipeSlot other) { + return order.compare(this, other); + } + + public Recipe toRecipe() { + return saved; + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTag.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTag.java new file mode 100644 index 0000000..cd02077 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTag.java @@ -0,0 +1,16 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.test.data.Tag; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record RecipeTag(int value) implements Tag, Comparable { + public static Comparator order = comparing(RecipeTag::value); + + public int compareTo(@Nullable RecipeTag other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTags.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTags.java new file mode 100644 index 0000000..be57d4b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTags.java @@ -0,0 +1,14 @@ +package eu.bitfield.recipes.test.core.recipe; + + +import eu.bitfield.recipes.test.data.Tags; + +public class RecipeTags extends Tags { + public static final RecipeTags recipes = new RecipeTags(); + public final RecipeTag minestrone = tag(), hummus = tag(), muhammara = tag(), tomatoSalad = tag(), + gigantesPlaki = tag(); + + protected RecipeTag tag(int value) { + return new RecipeTag(value); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplate.java new file mode 100644 index 0000000..b165a90 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplate.java @@ -0,0 +1,22 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.test.data.Template; +import lombok.With; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +@With +public record RecipeTemplate(RecipeTag tag, String name, String description) + implements Comparable, RecipeMask, Template { + public static Comparator order = + comparing(RecipeTemplate::tag) + .thenComparing(RecipeTemplate::name) + .thenComparing(RecipeTemplate::description); + + public int compareTo(@Nullable RecipeTemplate other) { + return order.compare(this, other); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplates.java new file mode 100644 index 0000000..ace547a --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/RecipeTemplates.java @@ -0,0 +1,36 @@ +package eu.bitfield.recipes.test.core.recipe; + + +import eu.bitfield.recipes.test.data.TemplateContainer; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; + +public class RecipeTemplates extends TemplateContainer { + public RecipeTemplates() { + add(recipes.minestrone, + "Minestrone", + "Minestrone or minestrone di verdure is a thick soup of Italian origin based on vegetables. It typically " + + "includes onions, carrots, celery, potatoes, cabbage, tomatoes, often legumes, such as beans, chickpeas " + + "or fava beans, and sometimes pasta or rice."); + add(recipes.hummus, + "Hummus", + "Hummus, is a Levantine dip, spread, or savory dish made from cooked, mashed chickpeas blended with " + + "tahini, lemon juice, and garlic."); + add(recipes.muhammara, + "Muhammara", + "Muhammara is a vibrant and flavorful dip or spread from the Middle East, particularly popular in Syrian " + + "and Turkish cuisine. It's made primarily from roasted red bell peppers, walnuts, breadcrumbs, and " + + "pomegranate molasses."); + add(recipes.tomatoSalad, + "Tomato Salad", + "A tomato salad is a light side dish filled with juicy ripe tomatoes and thinly sliced onions."); + add(recipes.gigantesPlaki, + "Gigantes Plaki", + "Gigantes plaki or Greek giant baked beans, is a Greek dish of large white beans baked in a tomato sauce."); + + } + + void add(RecipeTag tag, String name, String description) { + add(tag, new RecipeTemplate(tag, name, description)); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/recipe/ToRecipe.java b/src/test/java/eu/bitfield/recipes/test/core/recipe/ToRecipe.java new file mode 100644 index 0000000..7e2a1eb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/recipe/ToRecipe.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.test.core.recipe; + +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.recipe.RecipeOut; +import eu.bitfield.recipes.core.recipe.ToRecipeOut; + +public interface ToRecipe extends ToRecipeOut { + Recipe toRecipe(); + + default RecipeOut toRecipeOut() { + return toRecipe().toRecipeOut(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepActions.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepActions.java new file mode 100644 index 0000000..f6e792e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepActions.java @@ -0,0 +1,17 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +public interface StepActions { + Context stepContext(); + + MultiValueMap stepTemplates(); + + StepTemplate stepTemplate(RecipeTag recipeTag, long index); + + List stepTemplates(RecipeTag recipeTag); +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepLayer.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepLayer.java new file mode 100644 index 0000000..9fb533e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepLayer.java @@ -0,0 +1,32 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Context; +import lombok.RequiredArgsConstructor; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +import static java.util.Collections.*; + +@RequiredArgsConstructor +public class StepLayer implements StepActions { + private final StepTemplates templates = new StepTemplates(); + private final Context context = new Context<>(); + + public Context stepContext() { + return context; + } + + public MultiValueMap stepTemplates() { + return templates.byRecipe(); + } + + public StepTemplate stepTemplate(RecipeTag recipeTag, long index) { + return templates.get(new StepTemplates.Key(recipeTag, index)); + } + + public List stepTemplates(RecipeTag recipeTag) { + return stepTemplates().getOrDefault(recipeTag, emptyList()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepMask.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepMask.java new file mode 100644 index 0000000..5d93c59 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepMask.java @@ -0,0 +1,18 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.core.step.StepIn; +import eu.bitfield.recipes.core.step.ToStepIn; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; + + +public interface StepMask extends ToStepIn { + RecipeTag recipeTag(); + + long index(); + + String name(); + + default StepIn toStepIn() { + return new StepIn(name()); + } +} \ No newline at end of file diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepQueries.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepQueries.java new file mode 100644 index 0000000..71e4ef5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepQueries.java @@ -0,0 +1,103 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.test.core.recipe.RecipeQueries; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.SlotBuilder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Comparator.*; +import static java.util.stream.Collectors.*; + +public interface StepQueries extends RecipeQueries, StepActions { + default StepSlotBuilder stepSlot() { + return new StepSlotBuilder(this); + } + + default StepSlotBuilder stepSlot(StepTemplate template) { + return stepSlot().template(template); + } + + default StepSlotBuilder stepSlot(RecipeTag recipeTag, long index) { + return stepSlot(stepTemplate(recipeTag, index)); + } + + default Stream stepSlots(RecipeTag recipe) { + return stepTemplates(recipe).stream().map(this::stepSlot); + } + + default Stream stepSlots(RecipeSlot recipe) { + return stepSlots(recipe.tag()); + } + + default List recipeStepGroupsWithStepCounts(int... stepCounts) { + List recipeTagsInDescStepCountOrder = recipes.all() + .stream() + .sorted(comparing((RecipeTag recipeTag) -> stepTemplates(recipeTag).size())) + .collect(toCollection(ArrayList::new)); + List sortedStepCounts = IntStream.of(stepCounts).boxed().sorted(reverseOrder()).toList(); + MultiValueMap recipeTagsByStepCount = multiValueMap(); + for (int stepCount : sortedStepCounts) { + RecipeTag tag = recipeTagsInDescStepCountOrder.removeLast(); + recipeTagsByStepCount.add(stepCount, tag); + } + List groups = new ArrayList<>(); + for (int stepCount : stepCounts) { + RecipeTag recipeTag = recipeTagsByStepCount.get(stepCount).removeLast(); + groups.add(new RecipeStepGroup(recipeSlot(recipeTag), stepSlots(recipeTag).limit(stepCount).toList())); + } + return groups; + } + + default RecipeStepGroup recipeStepGroupWithStepCount(int stepCount) { + return recipeStepGroupsWithStepCounts(stepCount).getFirst(); + } + + record RecipeStepGroup(RecipeSlotBuilder recipe, List stepList) { + public Stream steps() { + return stepList.stream(); + } + } + + @RequiredArgsConstructor @Accessors(fluent = true) + class StepSlotBuilder implements SlotBuilder { + private final @Delegate StepQueries queries; + private @Getter @Setter @Nullable StepTemplate template = null; + private @Getter @Setter @Nullable RecipeSlot recipe = null; + + private void resolve() { + if (template != null) { + if (recipe != null && recipe.tag() != template.recipeTag()) { + throw new IllegalArgumentException("Recipe tag mismatch"); + } + + if (recipe == null) { + recipe = recipeSlot().template(recipeTemplate(template.recipeTag())).blank(); + } + return; + } + recipe = Optional.ofNullable(recipe).orElseGet(() -> recipeSlot().blank()); + template = Optional.ofNullable(template).orElseGet(() -> stepTemplate(recipe.tag(), 0)); + } + + public StepSlot blank() { + resolve(); + return stepContext().add(new StepSlot(template, recipe)); + } + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepSlot.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepSlot.java new file mode 100644 index 0000000..b990d1b --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepSlot.java @@ -0,0 +1,50 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.core.step.ToStepOut; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.data.AnyEntityToken; +import eu.bitfield.recipes.test.data.EntitySlot; +import eu.bitfield.recipes.test.data.EntityToken; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import lombok.experimental.Delegate; +import org.springframework.lang.Nullable; + +import java.util.Comparator; +import java.util.stream.Stream; + +import static java.util.Comparator.*; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) @EqualsAndHashCode +public class StepSlot implements Comparable, EntitySlot, StepMask, ToStepOut { + public static EntityToken stepToken = new EntityToken<>(6, StepSlot.class); + public static Comparator order = comparing(StepSlot::template).thenComparing(StepSlot::recipe); + private final @Delegate StepTemplate template; + private final RecipeSlot recipe; + private transient Step initial; + private transient @Delegate(types = ToStepOut.class) Step saved; + + public int compareTo(@Nullable StepSlot other) { + return order.compare(this, other); + } + + public void init() { + if (initial != null) throw new RuntimeException("Slot " + this + "was initialized twice"); + initial = Step.initial(recipe.id(), name()); + } + + public AnyEntityToken entityToken() { + return stepToken; + } + + public void save(Step saved) { + this.saved = saved; + } + + public Stream> saveDependencies() { + return Stream.of(recipe); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplate.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplate.java new file mode 100644 index 0000000..7edb464 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplate.java @@ -0,0 +1,23 @@ +package eu.bitfield.recipes.test.core.step; + + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.Template; +import org.springframework.lang.Nullable; + +import java.util.Comparator; + +import static java.util.Comparator.*; + +public record StepTemplate(RecipeTag recipeTag, long index, String name) + implements Comparable, StepMask, Template { + public static Comparator order = + comparing(StepTemplate::recipeTag) + .thenComparing(StepTemplate::index) + .thenComparing(StepTemplate::name); + + public int compareTo(@Nullable StepTemplate other) { + return order.compare(this, other); + } +} + diff --git a/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplates.java b/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplates.java new file mode 100644 index 0000000..08ef0ea --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/core/step/StepTemplates.java @@ -0,0 +1,65 @@ +package eu.bitfield.recipes.test.core.step; + +import eu.bitfield.recipes.test.core.recipe.RecipeTag; +import eu.bitfield.recipes.test.data.TemplateContainer; +import org.springframework.util.MultiValueMap; + +import static eu.bitfield.recipes.test.core.recipe.RecipeTags.*; +import static eu.bitfield.recipes.test.core.step.StepTemplates.Key.*; +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static org.springframework.util.CollectionUtils.*; + +public class StepTemplates extends TemplateContainer { + private final MultiValueMap byRecipe = multiValueMap(); + + public StepTemplates() { + add(recipes.minestrone, + names("Sauté the chopped onion, carrots, and celery for 5-7 minutes", + "Add the minced garlic and cook for another minute until fragrant.", + "Add the pasta, vegetables and tomatoes to the pot.", + "Bring to a simmer, then reduce heat, cover and wait until the pasta is al dente and the vegetables" + + " are tender.", + "Stir in the kale, near the end of the cooking time.")); + add(recipes.hummus, + names("Combine chickpeas, tahini, lemon juice, garlic, and salt in a food processor.", + "Blend until smooth, slowly adding cold water until you reach a creamy consistency.", + "Taste and adjust seasoning if needed.")); + + add(recipes.muhammara, + names("Roast whole bell peppers in an oven or air fryer and wait until blackened on the outside.", + "Put the peppers in a bowl cover them with to let steam and cool down", + "Remove core seeds, and skins", + "Combine all the ingredients in a food processor.", + "Process until mostly smooth but with a little texture remaining.", + "Taste and adjust flavor as needed (lemon for acidity, pomegranate molasses for sweetness)")); + + add(recipes.tomatoSalad, + names("Cut tomatoes in irregular pieces.", + "Finely chop the onions.", + "Combine with some balsamic vinegar.")); + } + + private String[] names(String... names) {return names;} + + private void add(RecipeTag recipeTag, String[] names) { + long index = 0; + for (String name : names) { + var template = new StepTemplate(recipeTag, index, name); + add(key(template), template); + byRecipe.add(recipeTag, template); + index++; + } + } + + public MultiValueMap byRecipe() {return unmodifiableMultiValueMap(byRecipe);} + + public record Key( + RecipeTag recipeTag, + long index + ) { + public static Key key(StepTemplate template) { + return new Key(template.recipeTag(), template.index()); + } + } + +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AnyAsyncEntityStorage.java b/src/test/java/eu/bitfield/recipes/test/data/AnyAsyncEntityStorage.java new file mode 100644 index 0000000..0c79c14 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AnyAsyncEntityStorage.java @@ -0,0 +1,12 @@ +package eu.bitfield.recipes.test.data; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface AnyAsyncEntityStorage { + AnyEntityToken entityToken(); + + Mono init(Flux> slots); + + Mono save(Flux> slots); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AnyEntityStorage.java b/src/test/java/eu/bitfield/recipes/test/data/AnyEntityStorage.java new file mode 100644 index 0000000..287515e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AnyEntityStorage.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.test.data; + +import java.util.stream.Stream; + +public interface AnyEntityStorage { + AnyEntityToken entityToken(); + + void init(Stream> slots); + + void save(Stream> slots); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AnyEntityToken.java b/src/test/java/eu/bitfield/recipes/test/data/AnyEntityToken.java new file mode 100644 index 0000000..2e122bb --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AnyEntityToken.java @@ -0,0 +1,7 @@ +package eu.bitfield.recipes.test.data; + +public interface AnyEntityToken { + int id(); + + Class> slotType(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AsyncEntityStorage.java b/src/test/java/eu/bitfield/recipes/test/data/AsyncEntityStorage.java new file mode 100644 index 0000000..7308d56 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AsyncEntityStorage.java @@ -0,0 +1,36 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.test.AsyncPersistence; +import eu.bitfield.recipes.util.Entity; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@RequiredArgsConstructor @Accessors(fluent = true) +public class AsyncEntityStorage, E extends Entity> implements AnyAsyncEntityStorage { + private final EntityToken token; + private final SlotInitializer initialization; + private final AsyncPersistence persistence; + + public Mono save(S slot) { + return Mono.defer(() -> { + initialization.init(slot); + return persistence.save(slot.initial()) + .doOnNext(slot::save) + .thenReturn(slot); + }); + } + + public AnyEntityToken entityToken() { + return token; + } + + public Mono init(Flux> slots) { + return slots.cast(token.slotType()).doOnNext(initialization::init).count(); + } + + public Mono save(Flux> slots) { + return slots.cast(token.slotType()).concatMap(this::save).count(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AsyncLayerFactory.java b/src/test/java/eu/bitfield/recipes/test/data/AsyncLayerFactory.java new file mode 100644 index 0000000..89ed2f5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AsyncLayerFactory.java @@ -0,0 +1,85 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.core.account.Account; +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.core.profile.Profile; +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.test.AsyncPersistence; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.core.step.StepSlot; +import lombok.RequiredArgsConstructor; + +import static eu.bitfield.recipes.test.core.account.AccountSlot.*; +import static eu.bitfield.recipes.test.core.category.CategorySlot.*; +import static eu.bitfield.recipes.test.core.ingredient.IngredientSlot.*; +import static eu.bitfield.recipes.test.core.link.LinkRecCatSlot.*; +import static eu.bitfield.recipes.test.core.profile.ProfileSlot.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeSlot.*; +import static eu.bitfield.recipes.test.core.step.StepSlot.*; + +@RequiredArgsConstructor +public class AsyncLayerFactory { + private final AsyncRootStorage rootStorage; + + public ProfileLayer profileLayer(AsyncPersistence persistence) { + var layer = new ProfileLayer(); + var storage = new AsyncEntityStorage<>(profileToken, ProfileSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public CategoryLayer categoryLayer(AsyncPersistence persistence) { + var layer = new CategoryLayer(); + var storage = new AsyncEntityStorage<>(categoryToken, CategorySlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public AccountLayer accountLayer(AsyncPersistence persistence) { + var layer = new AccountLayer(); + var storage = new AsyncEntityStorage<>(accountToken, layer, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public RecipeLayer recipeLayer(AsyncPersistence persistence) { + var layer = new RecipeLayer(); + var storage = new AsyncEntityStorage<>(recipeToken, layer, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public IngredientLayer ingredientLayer(AsyncPersistence persistence) { + var layer = new IngredientLayer(); + var storage = new AsyncEntityStorage<>(ingredientToken, IngredientSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public StepLayer stepLayer(AsyncPersistence persistence) { + var layer = new StepLayer(); + var storage = new AsyncEntityStorage<>(stepToken, StepSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public LinkRecCatLayer linkRecCatLayer(AsyncPersistence persistence) { + var layer = new LinkRecCatLayer(); + var storage = new AsyncEntityStorage<>(linkRecCatToken, LinkRecCatSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/AsyncRootStorage.java b/src/test/java/eu/bitfield/recipes/test/data/AsyncRootStorage.java new file mode 100644 index 0000000..9c9017f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/AsyncRootStorage.java @@ -0,0 +1,108 @@ +package eu.bitfield.recipes.test.data; + +import lombok.RequiredArgsConstructor; +import org.springframework.util.MultiValueMap; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.*; + +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Comparator.*; +import static java.util.stream.Collectors.*; +import static org.springframework.data.util.Predicates.*; + +public class AsyncRootStorage { + private final HashMap storageByType = new HashMap<>(); + + + public void addStorage(AnyAsyncEntityStorage storage) { + storageByType.put(storage.entityToken(), storage); + } + + public Mono save(EntitySlots slots) { + return Flux.fromIterable(slots.items()) + .filter(negate(EntitySlot::isSaved)) + .expand((EntitySlot slot) -> { + return Flux.fromStream(slot.saveDependencies()) + .filter(negate(EntitySlot::isSaved)); + }) + .collect(toCollection(HashSet::new)) + .map(SaveTask::new) + .repeat() + .takeWhile(SaveTask::unfinished) + .concatMap((SaveTask task) -> { + MultiValueMap> saveSlotsByType = + MultiValueMap.fromMultiValue(new TreeMap<>(comparing(AnyEntityToken::id))); + Iterator> slotIt = task.remainingSlotsToSave.iterator(); + while (slotIt.hasNext()) { + EntitySlot slot = slotIt.next(); + if (slot.isSavable()) { + slotIt.remove(); + saveSlotsByType.add(slot.entityToken(), slot); + } + } + if (saveSlotsByType.isEmpty() && task.prevSaveCount == 0) { + return Mono.error(new RuntimeException("No save progress could be made")); + } + return Flux.fromIterable(saveSlotsByType.entrySet()) + .concatMap(entryFn((AnyEntityToken type, List> saveSlotsTypeGroup) -> { + AnyAsyncEntityStorage storage = storageByType.get(type); + return storage.save(Flux.fromIterable(saveSlotsTypeGroup).sort()); + })) + .reduce(Long::sum) + .map((Long saveCount) -> { + task.prevSaveCount = saveCount; + return task; + }) + .then(); + }) + .then() + .cache(); + } + + public Mono init(EntitySlots slots) { + return Flux.fromIterable(slots.items()) + .filter(negate(EntitySlot::isInitialized)) + .collect(toCollection(HashSet::new)) + .delayUntil((HashSet> initSlots) -> { + return Flux.fromIterable(initSlots) + .map(EntitySlot::saveDependencies) + .concatMap(Flux::fromStream) + .collectList() + .map(EntitySlots::new) + .flatMap(this::save); + }) + .flatMapMany((HashSet> initSlots) -> { + MultiValueMap> initSlotsByType = + MultiValueMap.fromMultiValue(new TreeMap<>(comparing(AnyEntityToken::id))); + Iterator> slotIt = initSlots.iterator(); + while (slotIt.hasNext()) { + EntitySlot slot = slotIt.next(); + if (!slot.isInitializable()) { + return Flux.error(new RuntimeException("Slot " + slot + "can't be initialized")); + } + slotIt.remove(); + initSlotsByType.add(slot.entityToken(), slot); + } + return Flux.fromIterable(initSlotsByType.entrySet()); + }) + .concatMap(entryFn((AnyEntityToken type, List> initSlotsTypeGroup) -> { + AnyAsyncEntityStorage storage = storageByType.get(type); + return storage.init(Flux.fromIterable(initSlotsTypeGroup).sort()); + })) + .then() + .cache(); + + } + + @RequiredArgsConstructor + private static class SaveTask { + final Set> remainingSlotsToSave; + long prevSaveCount = 0; + + boolean unfinished() { + return !remainingSlotsToSave.isEmpty(); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Context.java b/src/test/java/eu/bitfield/recipes/test/data/Context.java new file mode 100644 index 0000000..de34b83 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Context.java @@ -0,0 +1,13 @@ +package eu.bitfield.recipes.test.data; + +import java.util.LinkedHashMap; + +import static java.util.function.Function.*; + +public class Context { + private final LinkedHashMap map = new LinkedHashMap<>(); + + public E add(E e) { + return map.computeIfAbsent(e, identity()); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/EntitySlot.java b/src/test/java/eu/bitfield/recipes/test/data/EntitySlot.java new file mode 100644 index 0000000..5c10e7e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/EntitySlot.java @@ -0,0 +1,29 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.util.Entity; + +import java.util.stream.Stream; + +public interface EntitySlot extends SavedEntity { + AnyEntityToken entityToken(); + + void save(E saved); + + default boolean isInitialized() { + return this.initial() != null; + } + + default boolean isSaved() { + return this.saved() != null; + } + + default boolean isInitializable() { + return saveDependencies().allMatch(EntitySlot::isSaved); + } + + default boolean isSavable() { + return isInitializable(); + } + + Stream> saveDependencies(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/EntitySlots.java b/src/test/java/eu/bitfield/recipes/test/data/EntitySlots.java new file mode 100644 index 0000000..1b34a6f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/EntitySlots.java @@ -0,0 +1,71 @@ +package eu.bitfield.recipes.test.data; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collector; + +import static eu.bitfield.recipes.util.To.*; +import static java.util.stream.Collectors.*; + +public record EntitySlots(Set> items) { + public EntitySlots(Collection> items) { + this(new HashSet<>(items)); + } + + public EntitySlots() { + this(new HashSet<>()); + } + + public static EntitySlots combinedSlots(Collection values) { + return values.stream().map(toSlots).collect(combineSlots()); + } + + public static EntitySlots combinedSlots(ToEntitySlots... values) { + return combinedSlots(List.of(values)); + } + + public static EntitySlots slots(Collection> slots) { + return new EntitySlots(slots); + } + + private static Collector combineSlots() { + return collectingAndThen(reducing(EntitySlots::add), + slots -> slots.orElseGet(EntitySlots::new)); + } + + public static EntitySlots slots(EntitySlot... slots) { + return slots(List.of(slots)); + } + + public static EntitySlots slots(ToEntitySlots value) { + return value.toEntitySlots(); + } + + public static EntitySlots slot(EntitySlot slot) { + return new EntitySlots(List.of(slot)); + } + + public EntitySlots add(EntitySlots slots) { + return add(slots.items); + } + + public EntitySlots add(EntitySlot... slots) { + return add(List.of(slots)); + } + + public EntitySlots add(ToEntitySlots value) { + return add(value.toEntitySlots()); + } + + public EntitySlots add(Collection> slots) { + items.addAll(slots); + return this; + } + + public EntitySlots add(EntitySlot slot) { + items.add(slot); + return this; + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/EntityStorage.java b/src/test/java/eu/bitfield/recipes/test/data/EntityStorage.java new file mode 100644 index 0000000..732ab87 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/EntityStorage.java @@ -0,0 +1,35 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.test.Persistence; +import eu.bitfield.recipes.util.Entity; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.stream.Stream; + +@RequiredArgsConstructor @Accessors(fluent = true) +public class EntityStorage, E extends Entity> implements AnyEntityStorage { + private final EntityToken token; + private final SlotInitializer initialization; + private final Persistence persistence; + + public void save(S slot) { + initialization.init(slot); + slot.save(persistence.save(slot.initial())); + } + + public AnyEntityToken entityToken() { + return token; + } + + public void init(Stream> slots) { + slots.forEach(anySlot -> { + S slot = token.slotType().cast(anySlot); + initialization.init(slot); + }); + } + + public void save(Stream> slots) { + slots.map(token.slotType()::cast).forEach(this::save); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/EntityToken.java b/src/test/java/eu/bitfield/recipes/test/data/EntityToken.java new file mode 100644 index 0000000..a71f827 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/EntityToken.java @@ -0,0 +1,11 @@ +package eu.bitfield.recipes.test.data; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +@RequiredArgsConstructor @Getter @Accessors(fluent = true) +public class EntityToken> implements AnyEntityToken { + private final int id; + private final Class slotType; +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Initial.java b/src/test/java/eu/bitfield/recipes/test/data/Initial.java new file mode 100644 index 0000000..7c6e08f --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Initial.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface Initial { + T initial(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/InitialEntity.java b/src/test/java/eu/bitfield/recipes/test/data/InitialEntity.java new file mode 100644 index 0000000..2e40487 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/InitialEntity.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.util.Entity; + +public interface InitialEntity extends Initial {} diff --git a/src/test/java/eu/bitfield/recipes/test/data/LayerFactory.java b/src/test/java/eu/bitfield/recipes/test/data/LayerFactory.java new file mode 100644 index 0000000..59b9e0e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/LayerFactory.java @@ -0,0 +1,113 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.core.account.Account; +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.core.profile.Profile; +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.test.Persistence; +import eu.bitfield.recipes.test.core.account.AccountLayer; +import eu.bitfield.recipes.test.core.category.CategoryLayer; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.ingredient.IngredientLayer; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatLayer; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.profile.ProfileLayer; +import eu.bitfield.recipes.test.core.profile.ProfileSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeLayer; +import eu.bitfield.recipes.test.core.step.StepLayer; +import eu.bitfield.recipes.test.core.step.StepSlot; +import lombok.RequiredArgsConstructor; + +import static eu.bitfield.recipes.test.core.account.AccountSlot.*; +import static eu.bitfield.recipes.test.core.category.CategorySlot.*; +import static eu.bitfield.recipes.test.core.ingredient.IngredientSlot.*; +import static eu.bitfield.recipes.test.core.link.LinkRecCatSlot.*; +import static eu.bitfield.recipes.test.core.profile.ProfileSlot.*; +import static eu.bitfield.recipes.test.core.recipe.RecipeSlot.*; +import static eu.bitfield.recipes.test.core.step.StepSlot.*; + +@RequiredArgsConstructor +public class LayerFactory { + private final RootStorage rootStorage; + + public ProfileLayer profileLayer(Persistence persistence) { + var layer = new ProfileLayer(); + var storage = new EntityStorage<>(profileToken, ProfileSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public ProfileLayer profileLayer() { + return profileLayer(Persistence.persistence(Profile::withId)); + } + + public CategoryLayer categoryLayer(Persistence persistence) { + var layer = new CategoryLayer(); + var storage = new EntityStorage<>(categoryToken, CategorySlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public CategoryLayer categoryLayer() { + return categoryLayer(Persistence.persistence(Category::withId)); + } + + public AccountLayer accountLayer(Persistence persistence) { + var layer = new AccountLayer(); + var storage = new EntityStorage<>(accountToken, layer, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public AccountLayer accountLayer() { + return accountLayer(Persistence.persistence(Account::withId)); + } + + public RecipeLayer recipeLayer(Persistence persistence) { + var layer = new RecipeLayer(); + var storage = new EntityStorage<>(recipeToken, layer, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public RecipeLayer recipeLayer() { + return recipeLayer(Persistence.persistence(Recipe::withId)); + } + + public IngredientLayer ingredientLayer(Persistence persistence) { + var layer = new IngredientLayer(); + var storage = new EntityStorage<>(ingredientToken, IngredientSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public IngredientLayer ingredientLayer() { + return ingredientLayer(Persistence.persistence(Ingredient::withId)); + } + + public StepLayer stepLayer(Persistence persistence) { + var layer = new StepLayer(); + var storage = new EntityStorage<>(stepToken, StepSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public StepLayer stepLayer() { + return stepLayer(Persistence.persistence(Step::withId)); + } + + public LinkRecCatLayer linkRecCatLayer(Persistence persistence) { + var layer = new LinkRecCatLayer(); + var storage = new EntityStorage<>(linkRecCatToken, LinkRecCatSlot::init, persistence); + rootStorage.addStorage(storage); + return layer; + } + + public LinkRecCatLayer linkRecCatLayer() { + return linkRecCatLayer(Persistence.persistence(LinkRecCat::withId)); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/RootStorage.java b/src/test/java/eu/bitfield/recipes/test/data/RootStorage.java new file mode 100644 index 0000000..ddd38f0 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/RootStorage.java @@ -0,0 +1,77 @@ +package eu.bitfield.recipes.test.data; + +import org.springframework.util.MultiValueMap; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static eu.bitfield.recipes.util.CollectionUtils.*; +import static java.util.Comparator.*; +import static org.springframework.data.util.Predicates.*; + +public class RootStorage { + private final HashMap storageByType = new HashMap<>(); + + + public void addStorage(AnyEntityStorage storage) { + storageByType.put(storage.entityToken(), storage); + } + + private void visitSavableSlots(EntitySlot slot, Consumer> visitor) { + if (slot.isSaved()) return; + visitor.accept(slot); + slot.saveDependencies().forEach(saveDependency -> visitSavableSlots(saveDependency, visitor)); + } + + public void save(EntitySlots slots) { + var t = this; + Set> remainingSlots = slots.items().stream().mapMulti(t::visitSavableSlots).collect(toHashSet()); + while (!remainingSlots.isEmpty()) { + MultiValueMap> slotsByType = + multiValueMap(new TreeMap<>(comparing(AnyEntityToken::id))); + Iterator> slotIt = remainingSlots.iterator(); + while (slotIt.hasNext()) { + EntitySlot slot = slotIt.next(); + if (slot.isSavable()) { + slotIt.remove(); + slotsByType.add(slot.entityToken(), slot); + } + } + if (slotsByType.isEmpty()) { + throw new RuntimeException("No save progress could be made"); + } + + for (var entry : slotsByType.entrySet()) { + AnyEntityToken type = entry.getKey(); + List> savableSlots = entry.getValue(); + AnyEntityStorage storage = storageByType.get(type); + storage.save(savableSlots.stream().sorted()); + } + } + } + + public void init(EntitySlots slots) { + Predicate> notInitialized = negate(EntitySlot::isInitialized); + Set> initSlots = slots.items().stream().filter(notInitialized).collect(toHashSet()); + save(EntitySlots.slots(initSlots.stream().flatMap(EntitySlot::saveDependencies).toList())); + MultiValueMap> slotsByType = + multiValueMap(new TreeMap<>(comparing(AnyEntityToken::id))); + Iterator> slotIt = initSlots.iterator(); + while (slotIt.hasNext()) { + EntitySlot slot = slotIt.next(); + if (!slot.isInitializable()) { + throw new RuntimeException("Slot " + slot + "can't be initialized"); + } + slotIt.remove(); + slotsByType.add(slot.entityToken(), slot); + } + + for (var entry : slotsByType.entrySet()) { + AnyEntityToken type = entry.getKey(); + List> savableSlots = entry.getValue(); + AnyEntityStorage storage = storageByType.get(type); + storage.init(savableSlots.stream().sorted()); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Saved.java b/src/test/java/eu/bitfield/recipes/test/data/Saved.java new file mode 100644 index 0000000..4e75168 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Saved.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface Saved { + T saved(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/SavedEntity.java b/src/test/java/eu/bitfield/recipes/test/data/SavedEntity.java new file mode 100644 index 0000000..92358f8 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/SavedEntity.java @@ -0,0 +1,10 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.util.Entity; +import eu.bitfield.recipes.util.Id; + +public interface SavedEntity extends Saved, Id, InitialEntity { + default long id() { + return saved().id(); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/SlotBuilder.java b/src/test/java/eu/bitfield/recipes/test/data/SlotBuilder.java new file mode 100644 index 0000000..10a88d1 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/SlotBuilder.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface SlotBuilder> { + S blank(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/SlotInitializer.java b/src/test/java/eu/bitfield/recipes/test/data/SlotInitializer.java new file mode 100644 index 0000000..383d55a --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/SlotInitializer.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface SlotInitializer> { + void init(S slot); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Tag.java b/src/test/java/eu/bitfield/recipes/test/data/Tag.java new file mode 100644 index 0000000..5ad7888 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Tag.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface Tag { + int value(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Tags.java b/src/test/java/eu/bitfield/recipes/test/data/Tags.java new file mode 100644 index 0000000..c0963c4 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Tags.java @@ -0,0 +1,24 @@ +package eu.bitfield.recipes.test.data; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Collections.*; + + +public abstract class Tags { + private final List tags = new ArrayList<>(); + private int counter = 0; + + abstract protected T tag(int value); + + protected T tag() { + T tag = tag(counter++); + tags.add(tag); + return tag; + } + + public List all() { + return unmodifiableList(tags); + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Template.java b/src/test/java/eu/bitfield/recipes/test/data/Template.java new file mode 100644 index 0000000..5378229 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Template.java @@ -0,0 +1,3 @@ +package eu.bitfield.recipes.test.data; + +public interface Template {} diff --git a/src/test/java/eu/bitfield/recipes/test/data/TemplateContainer.java b/src/test/java/eu/bitfield/recipes/test/data/TemplateContainer.java new file mode 100644 index 0000000..0c86801 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/TemplateContainer.java @@ -0,0 +1,22 @@ +package eu.bitfield.recipes.test.data; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import static java.util.Collections.unmodifiableCollection; +import static org.assertj.core.api.Assertions.assertThat; + +public class TemplateContainer { + private final Map templates = new HashMap<>(); + + protected T add(K key, T template) { + T prev = templates.put(key, template); + assertThat(prev).isNull(); + return template; + } + + public final Collection all() {return unmodifiableCollection(templates.values());} + + public T get(K key) {return templates.get(key);} +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/Templates.java b/src/test/java/eu/bitfield/recipes/test/data/Templates.java new file mode 100644 index 0000000..46d8c9d --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/Templates.java @@ -0,0 +1,21 @@ +package eu.bitfield.recipes.test.data; + +import eu.bitfield.recipes.test.core.account.AccountTemplates; +import eu.bitfield.recipes.test.core.category.CategoryTemplates; +import eu.bitfield.recipes.test.core.ingredient.IngredientTemplates; +import eu.bitfield.recipes.test.core.link.LinkRecCatTemplates; +import eu.bitfield.recipes.test.core.profile.ProfileTemplates; +import eu.bitfield.recipes.test.core.recipe.RecipeTemplates; +import eu.bitfield.recipes.test.core.step.StepTemplates; +import org.springframework.stereotype.Component; + +@Component +public class Templates { + public final ProfileTemplates profile = new ProfileTemplates(); + public final AccountTemplates account = new AccountTemplates(); + public final RecipeTemplates recipe = new RecipeTemplates(); + public final IngredientTemplates ingredient = new IngredientTemplates(); + public final StepTemplates step = new StepTemplates(); + public final CategoryTemplates category = new CategoryTemplates(); + public final LinkRecCatTemplates link = new LinkRecCatTemplates(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/data/ToEntitySlots.java b/src/test/java/eu/bitfield/recipes/test/data/ToEntitySlots.java new file mode 100644 index 0000000..f765d99 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/data/ToEntitySlots.java @@ -0,0 +1,5 @@ +package eu.bitfield.recipes.test.data; + +public interface ToEntitySlots { + EntitySlots toEntitySlots(); +} diff --git a/src/test/java/eu/bitfield/recipes/test/view/recipe/RecipeViewQueries.java b/src/test/java/eu/bitfield/recipes/test/view/recipe/RecipeViewQueries.java new file mode 100644 index 0000000..a03b63e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/view/recipe/RecipeViewQueries.java @@ -0,0 +1,104 @@ +package eu.bitfield.recipes.test.view.recipe; + +import eu.bitfield.recipes.core.category.Category; +import eu.bitfield.recipes.core.category.CategoryIn; +import eu.bitfield.recipes.core.ingredient.Ingredient; +import eu.bitfield.recipes.core.ingredient.IngredientIn; +import eu.bitfield.recipes.core.link.LinkRecCat; +import eu.bitfield.recipes.core.recipe.Recipe; +import eu.bitfield.recipes.core.recipe.RecipeIn; +import eu.bitfield.recipes.core.step.Step; +import eu.bitfield.recipes.core.step.StepIn; +import eu.bitfield.recipes.test.core.category.CategorySlot; +import eu.bitfield.recipes.test.core.ingredient.IngredientQueries; +import eu.bitfield.recipes.test.core.ingredient.IngredientSlot; +import eu.bitfield.recipes.test.core.link.LinkRecCatQueries; +import eu.bitfield.recipes.test.core.link.LinkRecCatSlot; +import eu.bitfield.recipes.test.core.recipe.RecipeSlot; +import eu.bitfield.recipes.test.core.recipe.ToRecipe; +import eu.bitfield.recipes.test.core.step.StepQueries; +import eu.bitfield.recipes.test.core.step.StepSlot; +import eu.bitfield.recipes.test.data.EntitySlots; +import eu.bitfield.recipes.test.data.ToEntitySlots; +import eu.bitfield.recipes.view.recipe.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.List; +import java.util.stream.Stream; + +import static eu.bitfield.recipes.util.To.*; + +public interface RecipeViewQueries extends LinkRecCatQueries, StepQueries, IngredientQueries { + default RecipeViewIn invalid(RecipeViewIn viewIn) { + return viewIn.withRecipe(invalid(viewIn.recipe())); + } + + default RecipeViewGroup recipeViewGroup(RecipeSlot recipe) { + List categories = linkedCategorySlots(recipe).map(to::blank).toList(); + List links = linkRecCatSlots(recipe).map(to::blank).toList(); + List ingredients = ingredientSlots(recipe).map(to::blank).toList(); + List steps = stepSlots(recipe).map(to::blank).toList(); + return new RecipeViewGroup(recipe, categories, links, ingredients, steps); + } + + default RecipeViewGroup recipeViewGroup() { + return recipeViewGroup(recipeSlot().blank()); + } + + default Stream recipeViewGroups() { + return recipeSlots().map(to::blank).map(this::recipeViewGroup); + } + + + @RequiredArgsConstructor @Accessors(fluent = true) + class RecipeViewGroup implements ToEntitySlots, ToRecipe, ToRecipeViewIn, ToRecipeView, ToRecipeViewOut { + private final @Getter RecipeSlot recipe; + private final @Getter List categories; + private final @Getter List links; + private final @Getter List ingredients; + private final @Getter List steps; + private RecipeView recipeView; + private RecipeViewOut recipeViewOut; + private RecipeViewIn recipeViewIn; + + public EntitySlots toEntitySlots() { + return EntitySlots.slot(recipe).add(categories).add(links).add(ingredients).add(steps); + } + + public RecipeView toRecipeView() { + if (recipeView == null) { + Recipe savedRecipe = recipe.saved(); + List savedCategories = categories.stream().map(to::saved).toList(); + List savedLinks = links.stream().map(to::saved).toList(); + List savedIngredients = ingredients.stream().map(to::saved).toList(); + List savedSteps = steps.stream().map(to::saved).toList(); + recipeView = new RecipeView(savedRecipe, savedCategories, savedLinks, savedIngredients, savedSteps); + } + return recipeView; + } + + public RecipeViewOut toRecipeViewOut() { + if (recipeViewOut == null) { + recipeViewOut = toRecipeView().toRecipeViewOut(); + } + return recipeViewOut; + } + + public RecipeViewIn toRecipeViewIn() { + if (recipeViewIn == null) { + RecipeIn recipeIn = recipe.toRecipeIn(); + List categoriesIn = categories.stream().map(toCategoryIn).toList(); + List ingredientsIn = ingredients.stream().map(toIngredientIn).toList(); + List stepsIn = steps.stream().map(toStepIn).toList(); + recipeViewIn = new RecipeViewIn(recipeIn, categoriesIn, ingredientsIn, stepsIn); + } + return recipeViewIn; + } + + public Recipe toRecipe() { + return recipe.toRecipe(); + } + } +} diff --git a/src/test/java/eu/bitfield/recipes/test/view/recipe/ToRecipeView.java b/src/test/java/eu/bitfield/recipes/test/view/recipe/ToRecipeView.java new file mode 100644 index 0000000..f242eca --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/test/view/recipe/ToRecipeView.java @@ -0,0 +1,7 @@ +package eu.bitfield.recipes.test.view.recipe; + +import eu.bitfield.recipes.view.recipe.RecipeView; + +public interface ToRecipeView { + RecipeView toRecipeView(); +} diff --git a/src/test/java/eu/bitfield/recipes/util/TestUtils.java b/src/test/java/eu/bitfield/recipes/util/TestUtils.java new file mode 100644 index 0000000..d8398ac --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/util/TestUtils.java @@ -0,0 +1,28 @@ +package eu.bitfield.recipes.util; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.*; + +public class TestUtils { + public static Mono noSubscriptionMono() { + return Mono.error(() -> new IllegalStateException("Mono should not have been subscribed")); + } + + public static Flux noSubscriptionFlux() { + return Flux.error(IllegalStateException::new); + } + + public static Consumer> thatAllSatisfy(Consumer checkAssert) { + return checks -> assertThat(checks).allSatisfy(checkAssert); + } + + public static T assertNotNull(T value) { + assertThat(value).isNotNull(); + return value; + } +} diff --git a/src/test/java/eu/bitfield/recipes/util/To.java b/src/test/java/eu/bitfield/recipes/util/To.java new file mode 100644 index 0000000..5cb11b5 --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/util/To.java @@ -0,0 +1,63 @@ +package eu.bitfield.recipes.util; + +import eu.bitfield.recipes.core.category.CategoryIn; +import eu.bitfield.recipes.core.category.CategoryOut; +import eu.bitfield.recipes.core.category.ToCategoryIn; +import eu.bitfield.recipes.core.category.ToCategoryOut; +import eu.bitfield.recipes.core.ingredient.IngredientIn; +import eu.bitfield.recipes.core.ingredient.IngredientOut; +import eu.bitfield.recipes.core.ingredient.ToIngredientIn; +import eu.bitfield.recipes.core.ingredient.ToIngredientOut; +import eu.bitfield.recipes.core.recipe.*; +import eu.bitfield.recipes.core.step.StepIn; +import eu.bitfield.recipes.core.step.StepOut; +import eu.bitfield.recipes.core.step.ToStepIn; +import eu.bitfield.recipes.core.step.ToStepOut; +import eu.bitfield.recipes.test.core.recipe.ToRecipe; +import eu.bitfield.recipes.test.data.*; +import eu.bitfield.recipes.test.view.recipe.ToRecipeView; +import eu.bitfield.recipes.view.recipe.*; +import reactor.core.publisher.Flux; + +import java.util.function.Function; + +public interface To { + To to = new ToImpl(); + Function toCategoryIn = ToCategoryIn::toCategoryIn; + Function toCategoryOut = ToCategoryOut::toCategoryOut; + Function toStepIn = ToStepIn::toStepIn; + Function toStepOut = ToStepOut::toStepOut; + Function toIngredientIn = ToIngredientIn::toIngredientIn; + Function toIngredientOut = ToIngredientOut::toIngredientOut; + Function toRecipeIn = ToRecipeIn::toRecipeIn; + Function toRecipeOut = ToRecipeOut::toRecipeOut; + Function toRecipeView = ToRecipeView::toRecipeView; + Function toRecipeViewIn = ToRecipeViewIn::toRecipeViewIn; + Function toRecipeViewOut = ToRecipeViewOut::toRecipeViewOut; + Function toSlots = ToEntitySlots::toEntitySlots; + Function toRecipe = ToRecipe::toRecipe; + Function toId = Id::id; + + default > Flux saved(Flux flux) { + return flux.map(Saved::saved); + } + + default > T saved(S value) { + return value.saved(); + } + + default > Flux initial(Flux flux) { + return flux.map(Initial::initial); + } + + default > T initial(I value) { + return value.initial(); + } + + default , B extends SlotBuilder> S blank(B value) { + return value.blank(); + } +} + +record ToImpl() implements To {} + diff --git a/src/test/java/eu/bitfield/recipes/util/Transaction.java b/src/test/java/eu/bitfield/recipes/util/Transaction.java new file mode 100644 index 0000000..cebc02e --- /dev/null +++ b/src/test/java/eu/bitfield/recipes/util/Transaction.java @@ -0,0 +1,35 @@ +package eu.bitfield.recipes.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.reactive.TransactionalOperator; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@RequiredArgsConstructor +public class Transaction { + private final TransactionalOperator transactionalOperator; + + Mono withRollback(Mono mono) { + return transactionalOperator.execute(tx -> { + tx.setRollbackOnly(); + return mono; + }) + .next(); + } + + Flux withRollback(Flux flux) { + return transactionalOperator.execute(tx -> { + tx.setRollbackOnly(); + return flux; + }); + } + + public StepVerifier.FirstStep rollbackVerify(Mono mono) { + return StepVerifier.create(withRollback(mono)); + } + + public StepVerifier.FirstStep rollbackVerify(Flux flux) { + return StepVerifier.create(withRollback(flux)); + } +} diff --git a/src/test/resources/clean.sql b/src/test/resources/clean.sql new file mode 100644 index 0000000..cf76fe2 --- /dev/null +++ b/src/test/resources/clean.sql @@ -0,0 +1,27 @@ +DELETE +FROM account; +ALTER SEQUENCE account_id_seq RESTART WITH 1; + +DELETE +FROM category; +ALTER SEQUENCE category_id_seq RESTART WITH 1; + +DELETE +FROM ingredient; +ALTER SEQUENCE ingredient_id_seq RESTART WITH 1; + +DELETE +FROM profile; +ALTER SEQUENCE profile_id_seq RESTART WITH 1; + +DELETE +FROM recipe; +ALTER SEQUENCE recipe_id_seq RESTART WITH 1; + +DELETE +FROM link_rec_cat; +ALTER SEQUENCE link_rec_cat_id_seq RESTART WITH 1; + +DELETE +FROM step; +ALTER SEQUENCE step_id_seq RESTART WITH 1;