MME-9 Add ingredients to Recipe entity.

This commit is contained in:
Jesse Brault 2026-02-13 09:07:09 -06:00
parent a92084a35a
commit 98c89f6247
12 changed files with 291 additions and 61 deletions

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.MinIOTestsExtension;
import app.mealsmadeeasy.api.PostgresTestsExtension;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageException;
@ -36,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
// TODO: test prep/cooking/total times included
@SpringBootTest
@ExtendWith(PostgresTestsExtension.class)
@ExtendWith(MinIOTestsExtension.class)
public class RecipeServiceTests {
@Autowired
@ -107,16 +109,31 @@ public class RecipeServiceTests {
public void whenCreate_allFieldsTransferredFromSpec() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final List<RecipeCreateSpec.IngredientCreateSpec> ingredients = List.of(
RecipeCreateSpec.IngredientCreateSpec.builder()
.amount("1T")
.name("Butter")
.notes("softened")
.build(),
RecipeCreateSpec.IngredientCreateSpec.builder()
.amount("2T")
.name("Shortening")
.notes("vegan")
.build()
);
final RecipeCreateSpec spec = RecipeCreateSpec.builder()
.title("Recipe Title")
.slug("recipe-slug")
.preparationTime(15)
.cookingTime(30)
.totalTime(45)
.ingredients(ingredients)
.rawText("# Hello Recipe")
.isPublic(true)
.mainImage(image)
.build();
final Recipe recipe = this.recipeService.create(owner, spec, false);
assertThat(recipe.getId(), is(notNullValue()));
assertThat(recipe.getTitle(), is("Recipe Title"));
@ -124,6 +141,8 @@ public class RecipeServiceTests {
assertThat(recipe.getPreparationTime(), is(15));
assertThat(recipe.getCookingTime(), is(30));
assertThat(recipe.getTotalTime(), is(45));
assertThat(recipe.getIngredients(), is(notNullValue()));
assertThat(recipe.getIngredients().size(), is(2));
assertThat(recipe.getRawText(), is("# Hello Recipe"));
assertThat(recipe.getMainImage(), is(notNullValue()));
assertThat(recipe.getMainImage().getId(), is(image.getId()));
@ -305,6 +324,61 @@ public class RecipeServiceTests {
assertThat(viewableInfos, containsRecipes(r0, r1));
}
@Test
public void whenUpdate_allFieldsTransferred() throws Exception {
final User owner = this.seedUser();
final Recipe base = this.createTestRecipe(owner);
final List<RecipeUpdateSpec.IngredientUpdateSpec> ingredients = List.of(
RecipeUpdateSpec.IngredientUpdateSpec.builder()
.amount("1T")
.name("Butter")
.notes("Softened")
.build(),
RecipeUpdateSpec.IngredientUpdateSpec.builder()
.amount("2T")
.name("Shortening")
.notes("Vegan")
.build()
);
final Image image = this.seedImage(owner);
final RecipeUpdateSpec.MainImageUpdateSpec mainImageSpec = RecipeUpdateSpec.MainImageUpdateSpec.builder()
.username(image.getOwner().getUsername())
.filename(image.getUserFilename())
.build();
final RecipeUpdateSpec spec = RecipeUpdateSpec.builder()
.title("New Title")
.preparationTime(20)
.cookingTime(40)
.totalTime(60)
.ingredients(ingredients)
.rawText("Updated text.")
.isPublic(true)
.mainImage(mainImageSpec)
.build();
final Recipe updated = this.recipeService.update(
base.getOwner().getUsername(),
base.getSlug(),
spec,
owner
);
assertThat(updated.getId(), is(base.getId()));
assertThat(updated.getTitle(), is("New Title"));
assertThat(updated.getSlug(), is(base.getSlug()));
assertThat(updated.getPreparationTime(), is(20));
assertThat(updated.getCookingTime(), is(40));
assertThat(updated.getTotalTime(), is(60));
assertThat(updated.getIngredients(), is(notNullValue()));
assertThat(updated.getIngredients().size(), is(2));
assertThat(updated.getIsPublic(), is(true));
assertThat(updated.getMainImage(), is(notNullValue()));
assertThat(updated.getMainImage().getId(), is(image.getId()));
}
@Test
public void updateRawText() {
final User owner = this.seedUser();

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.MinIOTestsExtension;
import app.mealsmadeeasy.api.PostgresTestsExtension;
import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginDetails;
@ -25,6 +26,7 @@ import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.io.InputStream;
import java.util.List;
import java.util.UUID;
import static org.hamcrest.Matchers.*;
@ -35,6 +37,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(PostgresTestsExtension.class)
@ExtendWith(MinIOTestsExtension.class)
public class RecipesControllerTests {
private static InputStream getHal9000() {
@ -222,13 +225,39 @@ public class RecipesControllerTests {
public void updateRecipe() throws Exception {
final User owner = this.seedUser();
final Recipe recipe = this.createTestRecipe(owner, false);
final String accessToken = this.getAccessToken(owner);
final String body = this.getUpdateBody();
final Image image = this.createHal9000(owner);
final RecipeUpdateSpec spec = RecipeUpdateSpec.builder()
.title("Updated Test Recipe")
.preparationTime(15)
.cookingTime(30)
.totalTime(45)
.ingredients(List.of(
RecipeUpdateSpec.IngredientUpdateSpec.builder()
.amount("1T")
.name("Butter")
.notes("Softened")
.build(),
RecipeUpdateSpec.IngredientUpdateSpec.builder()
.amount("2T")
.name("Shortening")
.notes("Vegan")
.build()
))
.rawText("# Hello, Updated World!")
.isPublic(true)
.mainImage(RecipeUpdateSpec.MainImageUpdateSpec.builder()
.username(image.getOwner().getUsername())
.filename(image.getUserFilename())
.build())
.build();
final String updateBody = this.objectMapper.writeValueAsString(spec);
this.mockMvc.perform(
post("/recipes/{username}/{slug}", owner.getUsername(), recipe.getSlug())
.header("Authorization", "Bearer " + accessToken)
.header("Authorization", "Bearer " + this.getAccessToken(owner))
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.content(updateBody)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.recipe.id").value(recipe.getId()))
@ -236,6 +265,15 @@ public class RecipesControllerTests {
.andExpect(jsonPath("$.recipe.preparationTime").value(15))
.andExpect(jsonPath("$.recipe.cookingTime").value(30))
.andExpect(jsonPath("$.recipe.totalTime").value(45))
.andExpect(jsonPath("$.recipe.ingredients").isArray())
.andExpect(jsonPath("$.recipe.ingredients[0].amount").value("1T"))
.andExpect(jsonPath("$.recipe.ingredients[0].name").value("Butter"))
.andExpect(jsonPath("$.recipe.ingredients[0].notes").value("Softened"))
.andExpect(jsonPath("$.recipe.ingredients[1].amount").value("2T"))
.andExpect(jsonPath("$.recipe.ingredients[1].name").value("Shortening"))
.andExpect(jsonPath("$.recipe.ingredients[1].notes").value("Vegan"))
.andExpect(jsonPath("$.recipe.text").value("<h1>Hello, Updated World!</h1>"))
.andExpect(jsonPath("$.recipe.rawText").value("# Hello, Updated World!"))
.andExpect(jsonPath("$.recipe.owner.id").value(owner.getId()))
@ -243,7 +281,10 @@ public class RecipesControllerTests {
.andExpect(jsonPath("$.recipe.starCount").value(0))
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
.andExpect(jsonPath("$.recipe.public").value(true))
.andExpect(jsonPath("$.recipe.mainImage").value(nullValue()))
.andExpect(jsonPath("$.recipe.mainImage.owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.recipe.mainImage.filename").value(image.getUserFilename()))
.andExpect(jsonPath("$.isStarred").value(false))
.andExpect(jsonPath("$.isOwner").value(true));
}

View File

@ -4,14 +4,17 @@ import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.user.User;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.annotations.Type;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@ -21,20 +24,54 @@ import java.util.Set;
@ToString
public class Recipe {
@Getter
@Setter
@ToString
public static class Ingredient {
@Nullable
private String amount;
private String name;
@Nullable
private String notes;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o instanceof Ingredient other) {
return Objects.equals(this.amount, other.amount)
&& Objects.equals(this.name, other.name)
&& Objects.equals(this.notes, other.notes);
} else {
return false;
}
}
@Override
public int hashCode() {
return Objects.hash(this.amount, this.name, this.notes);
}
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false, updatable = false)
@Basic(optional = false)
@Column(updatable = false)
private Integer id;
@Column(nullable = false)
@Basic(optional = false)
private OffsetDateTime created;
private OffsetDateTime modified;
@Column(nullable = false, unique = true)
@Basic(optional = false)
@Column(unique = true)
private String slug;
@Column(nullable = false)
@Basic(optional = false)
private String title;
@Nullable
@ -46,7 +83,13 @@ public class Recipe {
@Nullable
private Integer totalTime;
@Column(columnDefinition = "TEXT", nullable = false)
@Type(JsonBinaryType.class)
@Column(columnDefinition = "JSONB")
@Nullable
private List<Ingredient> ingredients;
@Basic(optional = false)
@Column(columnDefinition = "TEXT")
private String rawText;
@Column(columnDefinition = "TEXT")
@ -63,7 +106,7 @@ public class Recipe {
@OneToMany(mappedBy = "recipe", orphanRemoval = true, cascade = CascadeType.ALL)
private Set<RecipeComment> comments = new HashSet<>();
@Column(nullable = false)
@Basic(optional = false)
private Boolean isPublic = false;
@ManyToMany

View File

@ -50,6 +50,20 @@ public class RecipeService {
draft.setOwner(owner);
draft.setSlug(spec.getSlug());
draft.setTitle(spec.getTitle());
if (spec.getIngredients() != null) {
draft.setIngredients(spec.getIngredients().stream()
.map(ingredientSpec -> {
final Recipe.Ingredient ingredient = new Recipe.Ingredient();
ingredient.setAmount(ingredientSpec.amount());
ingredient.setName(ingredientSpec.name());
ingredient.setNotes(ingredientSpec.notes());
return ingredient;
})
.toList()
);
}
draft.setRawText(spec.getRawText());
draft.setMainImage(spec.getMainImage());
draft.setIsPublic(spec.isPublic());
@ -57,12 +71,14 @@ public class RecipeService {
draft.setCookingTime(spec.getCookingTime());
draft.setTotalTime(spec.getTotalTime());
final Recipe saved = this.recipeRepository.save(draft);
if (queueSummaryJob) {
this.jobService.create(
RecipeSummaryJobHandler.JOB_KEY,
new RecipeSummaryJobHandler.RecipeSummaryJobPayload(saved.getId())
);
}
return saved;
}
@ -160,6 +176,19 @@ public class RecipeService {
recipe.setTotalTime(spec.getTotalTime());
}
if (spec.getIngredients() != null) {
recipe.setIngredients(spec.getIngredients().stream()
.map(ingredientSpec -> {
final Recipe.Ingredient ingredient = new Recipe.Ingredient();
ingredient.setAmount(ingredientSpec.amount());
ingredient.setName(ingredientSpec.name());
ingredient.setNotes(ingredientSpec.notes());
return ingredient;
})
.toList()
);
}
if (spec.getRawText() != null) {
recipe.setRawText(spec.getRawText());
recipe.setCachedRenderedText(null);

View File

@ -9,6 +9,7 @@ import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentView;
import app.mealsmadeeasy.api.recipe.converter.RecipeToFullViewConverter;
import app.mealsmadeeasy.api.recipe.converter.RecipeToInfoViewConverter;
import app.mealsmadeeasy.api.recipe.converter.RecipeUpdateBodyToSpecConverter;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
@ -43,6 +44,7 @@ public class RecipesController {
private final ObjectMapper objectMapper;
private final RecipeToFullViewConverter recipeToFullViewConverter;
private final RecipeToInfoViewConverter recipeToInfoViewConverter;
private final RecipeUpdateBodyToSpecConverter updateBodyToSpecConverter;
private Map<String, Object> getFullViewWrapper(String username, String slug, FullRecipeView view, @Nullable User viewer) {
Map<String, Object> wrapper = new HashMap<>();
@ -72,7 +74,7 @@ public class RecipesController {
@RequestBody RecipeUpdateBody updateBody,
@AuthenticationPrincipal User principal
) {
final RecipeUpdateSpec spec = RecipeUpdateSpec.from(updateBody);
final RecipeUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody);
final Recipe updated = this.recipeService.update(username, slug, spec, principal);
final FullRecipeView view = this.recipeToFullViewConverter.convert(updated, includeRawText, principal);
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal));

View File

@ -3,6 +3,8 @@ package app.mealsmadeeasy.api.recipe.body;
import lombok.Data;
import org.jetbrains.annotations.Nullable;
import java.util.List;
@Data
public class RecipeUpdateBody {
@ -12,10 +14,18 @@ public class RecipeUpdateBody {
private String filename;
}
@Data
public static class IngredientUpdateBody {
private @Nullable String amount;
private String name;
private @Nullable String notes;
}
private @Nullable String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private @Nullable List<IngredientUpdateBody> ingredients;
private @Nullable String rawText;
private @Nullable Boolean isPublic;
private @Nullable MainImageUpdateBody mainImage;

View File

@ -32,12 +32,26 @@ public class RecipeToFullViewConverter {
.starCount(this.recipeService.getStarCount(recipe))
.viewerCount(this.recipeService.getViewerCount(recipe))
.isPublic(recipe.getIsPublic());
if (recipe.getIngredients() != null) {
b.ingredients(recipe.getIngredients().stream()
.map(ingredient -> FullRecipeView.IngredientView.builder()
.amount(ingredient.getAmount())
.name(ingredient.getName())
.notes(ingredient.getNotes())
.build())
.toList()
);
}
if (recipe.getMainImage() != null) {
b.mainImage(this.imageToViewConverter.convert(recipe.getMainImage(), viewer, false));
}
if (includeRawText) {
b.rawText(recipe.getRawText());
}
return b.build();
}

View File

@ -0,0 +1,44 @@
package app.mealsmadeeasy.api.recipe.converter;
import app.mealsmadeeasy.api.recipe.body.RecipeUpdateBody;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import org.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Service;
@Service
public class RecipeUpdateBodyToSpecConverter {
public RecipeUpdateSpec convert(RecipeUpdateBody body) {
final var b = RecipeUpdateSpec.builder()
.title(body.getTitle())
.preparationTime(body.getPreparationTime())
.cookingTime(body.getCookingTime())
.totalTime(body.getTotalTime())
.rawText(body.getRawText())
.isPublic(body.getIsPublic());
if (body.getIngredients() != null) {
b.ingredients(body.getIngredients().stream()
.map(ingredient -> RecipeUpdateSpec.IngredientUpdateSpec.builder()
.amount(ingredient.getAmount())
.name(ingredient.getName())
.notes(ingredient.getNotes())
.build())
.toList()
);
}
final @Nullable RecipeUpdateBody.MainImageUpdateBody mainImage = body.getMainImage();
if (mainImage != null) {
b.mainImage(
RecipeUpdateSpec.MainImageUpdateSpec.builder()
.username(mainImage.getUsername())
.filename(mainImage.getFilename())
.build()
);
}
return b.build();
}
}

View File

@ -5,15 +5,27 @@ import lombok.Builder;
import lombok.Value;
import org.jetbrains.annotations.Nullable;
import java.util.List;
@Value
@Builder
public class RecipeCreateSpec {
@Builder
public record IngredientCreateSpec(
@Nullable String amount,
String name,
@Nullable String notes
) {}
String slug;
String title;
@Nullable Integer preparationTime;
@Nullable Integer cookingTime;
@Nullable Integer totalTime;
@Nullable List<IngredientCreateSpec> ingredients;
String rawText;
boolean isPublic;
@Nullable Image mainImage;
}

View File

@ -2,12 +2,13 @@ package app.mealsmadeeasy.api.recipe.spec;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.body.RecipeUpdateBody;
import lombok.Builder;
import lombok.Value;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Nullable;
import java.util.List;
// For now, we cannot change slug after creation.
// In the future, we may be able to have redirects from
// old slugs to new slugs.
@ -22,25 +23,8 @@ public class RecipeUpdateSpec {
String filename;
}
public static RecipeUpdateSpec from(RecipeUpdateBody body) {
final var b = RecipeUpdateSpec.builder()
.title(body.getTitle())
.preparationTime(body.getPreparationTime())
.cookingTime(body.getCookingTime())
.totalTime(body.getTotalTime())
.rawText(body.getRawText())
.isPublic(body.getIsPublic());
final @Nullable RecipeUpdateBody.MainImageUpdateBody mainImage = body.getMainImage();
if (mainImage != null) {
b.mainImage(
MainImageUpdateSpec.builder()
.username(mainImage.getUsername())
.filename(mainImage.getFilename())
.build()
);
}
return b.build();
}
@Builder
public record IngredientUpdateSpec(@Nullable String amount, String name, @Nullable String notes) {}
// For testing convenience only.
@ApiStatus.Internal
@ -67,6 +51,7 @@ public class RecipeUpdateSpec {
@Nullable Integer preparationTime;
@Nullable Integer cookingTime;
@Nullable Integer totalTime;
@Nullable List<IngredientUpdateSpec> ingredients;
String rawText;
Boolean isPublic;
@Nullable MainImageUpdateSpec mainImage;

View File

@ -1,7 +1,6 @@
package app.mealsmadeeasy.api.recipe.view;
import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.user.view.UserInfoView;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
@ -9,39 +8,14 @@ import lombok.Value;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.List;
@Value
@Builder
public class FullRecipeView {
public static FullRecipeView from(
Recipe recipe,
String renderedText,
boolean includeRawText,
int starCount,
int viewerCount,
@Nullable ImageView mainImage
) {
final var b = FullRecipeView.builder()
.id(recipe.getId())
.created(recipe.getCreated())
.modified(recipe.getModified())
.slug(recipe.getSlug())
.title(recipe.getTitle())
.preparationTime(recipe.getPreparationTime())
.cookingTime(recipe.getCookingTime())
.totalTime(recipe.getTotalTime())
.text(renderedText)
.owner(UserInfoView.from(recipe.getOwner()))
.starCount(starCount)
.viewerCount(viewerCount)
.mainImage(mainImage)
.isPublic(recipe.getIsPublic());
if (includeRawText) {
b.rawText(recipe.getRawText());
}
return b.build();
}
@Builder
public record IngredientView(@Nullable String amount, String name, @Nullable String notes) {}
Integer id;
OffsetDateTime created;
@ -51,6 +25,7 @@ public class FullRecipeView {
@Nullable Integer preparationTime;
@Nullable Integer cookingTime;
@Nullable Integer totalTime;
@Nullable List<IngredientView> ingredients;
String text;
@Nullable String rawText;
UserInfoView owner;

View File

@ -0,0 +1 @@
ALTER TABLE recipe ADD COLUMN ingredients JSONB;