diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java index 6e70111..a147dc7 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java @@ -6,6 +6,8 @@ import app.mealsmadeeasy.api.ai.OcrService; import app.mealsmadeeasy.api.auth.AuthService; import app.mealsmadeeasy.api.auth.LoginException; import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody; +import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler; +import app.mealsmadeeasy.api.recipe.view.RecipeDraftView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserService; @@ -14,17 +16,21 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.MimeTypeUtils; import java.util.List; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.Matchers.hasSize; import static org.junit.Assert.assertThrows; @@ -52,6 +58,12 @@ public class RecipeDraftsControllerIntegrationTests { @Autowired private ObjectMapper objectMapper; + @MockitoBean + private OcrService ocrService; + + @MockitoBean + private InferenceService inferenceService; + private static final String TEST_PASSWORD = "test"; private User seedUser() { @@ -177,12 +189,6 @@ public class RecipeDraftsControllerIntegrationTests { @Nested public class AiDraftTestsWithMocks { - @MockitoBean - private OcrService ocrService; - - @MockitoBean - private InferenceService inferenceService; - @Test public void whenAiDraft_returnsDraft() throws Exception { final User owner = seedUser(); @@ -190,7 +196,7 @@ public class RecipeDraftsControllerIntegrationTests { "sourceFile", "recipe.jpeg", MimeTypeUtils.IMAGE_JPEG_VALUE, - AiDraftTestsWithMocks.class.getResourceAsStream("recipe.jpeg") + this.getClass().getResourceAsStream("/recipe.jpeg") ); mockMvc.perform( multipart("/recipe-drafts/ai") @@ -205,6 +211,46 @@ public class RecipeDraftsControllerIntegrationTests { .andExpect(jsonPath("$.owner.username", is(owner.getUsername()))); } + @Test + public void pollingAfterAiDraft_returnsDraftWithInference() throws Exception { + Mockito.when(ocrService.readMarkdown(Mockito.any())).thenReturn("# Recipe"); + Mockito.when(inferenceService.extract( + Mockito.any(), + Mockito.any(), + Mockito.any() + )).thenReturn(new RecipeInferJobHandler.RecipeExtraction("Recipe", List.of())); + + final User owner = seedUser(); + final MockMultipartFile sourceFile = new MockMultipartFile( + "sourceFile", + "recipe.jpeg", + MimeTypeUtils.IMAGE_JPEG_VALUE, + this.getClass().getResourceAsStream("/recipe.jpeg") + ); + final MvcResult result = mockMvc.perform( + multipart("/recipe-drafts/ai") + .file(sourceFile) + .param("sourceFileName", sourceFile.getOriginalFilename()) + .header("Authorization", "Bearer " + getAccessToken(owner)) + ).andReturn(); + final UUID draftId = objectMapper.readValue( + result.getResponse().getContentAsString(), + RecipeDraftView.class + ).id(); + await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> { + mockMvc.perform( + get("/recipe-drafts/{id}", draftId) + .header("Authorization", "Bearer " + getAccessToken(owner)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString()))) + .andExpect(jsonPath("$.lastInference", is(notNullValue()))) + .andExpect(jsonPath("$.lastInference.inferredAt", is(notNullValue()))) + .andExpect(jsonPath("$.lastInference.title", is("Recipe"))) + .andExpect(jsonPath("$.lastInference.rawText", is("# Recipe"))); + }); + } + } private static RecipeDraftUpdateBody getTestRecipeDraftUpdateBody() { diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java index d95774e..19f4697 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftToViewConverter.java @@ -10,6 +10,8 @@ import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; +import java.util.Comparator; + @Component @RequiredArgsConstructor public class RecipeDraftToViewConverter { @@ -20,6 +22,17 @@ public class RecipeDraftToViewConverter { final @Nullable ImageView mainImageView = recipeDraft.getMainImage() != null ? this.imageToViewConverter.convert(recipeDraft.getMainImage(), viewer, false) : null; + final @Nullable RecipeDraftView.RecipeDraftInferenceView lastInference = recipeDraft.getInferences() != null + ? recipeDraft.getInferences().stream() + .max(Comparator.comparing(RecipeDraft.RecipeDraftInference::getInferredAt)) + .map(inference -> RecipeDraftView.RecipeDraftInferenceView.builder() + .inferredAt(inference.getInferredAt()) + .title(inference.getTitle()) + .rawText(inference.getRawText()) + .build() + ) + .orElse(null) + : null; return RecipeDraftView.builder() .id(recipeDraft.getId()) .created(recipeDraft.getCreated()) @@ -34,6 +47,7 @@ public class RecipeDraftToViewConverter { .ingredients(recipeDraft.getIngredients()) .owner(UserInfoView.from(recipeDraft.getOwner())) .mainImage(mainImageView) + .lastInference(lastInference) .build(); } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java index de45eac..76d2edb 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/job/RecipeInferJobHandler.java @@ -10,6 +10,7 @@ import app.mealsmadeeasy.api.recipe.RecipeDraft; import app.mealsmadeeasy.api.recipe.RecipeService; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,12 +33,14 @@ import java.util.List; @RequiredArgsConstructor public class RecipeInferJobHandler implements JobHandler { - private record RecipeExtraction( + @ApiStatus.Internal + public record RecipeExtraction( @JsonProperty(required = true) String title, @JsonProperty(required = true) List ingredients ) {} - private record IngredientExtraction( + @ApiStatus.Internal + public record IngredientExtraction( String amount, @JsonProperty(required = true) String name, String notes diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java b/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java index 4f08b7f..e095794 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java @@ -24,5 +24,15 @@ public record RecipeDraftView( @Nullable String rawText, @Nullable List ingredients, UserInfoView owner, - @Nullable ImageView mainImage -) {} + @Nullable ImageView mainImage, + @Nullable RecipeDraftInferenceView lastInference +) { + + @Builder + public record RecipeDraftInferenceView( + OffsetDateTime inferredAt, + String title, + String rawText + ) {} + +}