feat: Recipe management with ingredients, steps, images, and shopping list integration#64
feat: Recipe management with ingredients, steps, images, and shopping list integration#64
Conversation
- Add Recipe, RecipeIngredient, RecipeStep, RecipeImage entities to ApplicationDbContext
- Add EF Core model configuration (max lengths, FKs with cascade delete, soft delete)
- Create RecipeEndpoints.cs with full CRUD for recipes, ingredients, steps, images
- Add POST /api/recipes/{id}/add-to-shopping-list endpoint
- Register MapRecipeEndpoints() in Program.cs
- Add AddRecipes EF Core migration
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Append Recipe, RecipeIngredient, RecipeStep, RecipeImage interfaces and Create/Update request interfaces to models/index.ts with discriminator, deserialize, and serialize functions - Create api/recipes/index.ts collection builder (GET list, POST create) - Create api/recipes/item/index.ts item builder (GET, PUT void, DELETE) with navigation to ingredients, steps, images, addToShoppingList - Create api/recipes/item/ingredients/index.ts and item/index.ts - Create api/recipes/item/steps/index.ts and item/index.ts - Create api/recipes/item/images/index.ts and item/index.ts (DELETE only) - Create api/recipes/item/addToShoppingList/index.ts (POST with body) - Update api/index.ts to add recipes property and navigation metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add useRecipes.ts with hooks for recipes, ingredients, steps, images, and add-to-shopping-list - Add useRecipes.test.tsx with 10 passing tests - Add app/recipes/page.tsx (list page with create/delete) - Add app/recipes/[id]/page.tsx (detail page with ingredients, steps, images, add-to-shopping-list) - Update app/page.tsx to add Recipes navigation button Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This pull request implements a comprehensive recipe management system with full CRUD operations, nested resources (ingredients, steps, images), and shopping list integration. The feature allows users to create and manage recipes with structured data including ingredients (with amounts, units, and groups), ordered preparation steps, image URLs, and optional external links. The implementation follows the existing codebase patterns for soft deletes, authorization, and endpoint configuration.
Changes:
- Backend: New Recipe entities with EF Core migration, 16 API endpoints for recipe CRUD and nested resources, plus shopping list integration endpoint
- Frontend: Kiota-generated API client, React hooks for all operations, list and detail pages with edit modes, plus comprehensive unit tests
- Integration: Added shopping list button to home page, ingredients can be bulk-added to any shopping list with automatic recommendation seeding
Reviewed changes
Copilot reviewed 24 out of 68 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Anything.API/Program.cs | Registers new RecipeEndpoints |
| src/Anything.API/Endpoints/RecipeEndpoints.cs | Implements 16 API endpoints for recipes, ingredients, steps, images, and shopping list integration |
| src/Anything.API/Data/ApplicationDbContext.cs | Adds Recipe, RecipeIngredient, RecipeStep, RecipeImage entities with configuration |
| src/Anything.API/Migrations/*.cs | EF Core migration creating four new tables with foreign keys and indexes |
| anything-frontend/src/lib/api-client/* | Manually generated Kiota API client for recipe endpoints |
| anything-frontend/src/hooks/useRecipes.ts | 14 React hooks for recipe operations with React Query |
| anything-frontend/src/hooks/useRecipes.test.tsx | Unit tests for recipe hooks |
| anything-frontend/src/app/recipes/page.tsx | Recipe list page with create/delete functionality |
| anything-frontend/src/app/recipes/[id]/page.tsx | Recipe detail page with edit mode for all nested resources |
| anything-frontend/src/app/page.tsx | Adds Recipes navigation button |
| anything-frontend/package-lock.json | Dependency updates (peer dependencies marking) |
Files not reviewed (2)
- anything-frontend/package-lock.json: Language not supported
- src/Anything.API/Migrations/20260223134937_AddRecipes.Designer.cs: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ? $"{ingredient.Amount:G29} {ingredient.Name}" | ||
| : $"{ingredient.Amount:G29} {ingredient.Unit} {ingredient.Name}").ToList(); |
There was a problem hiding this comment.
The format specifier G29 is used for decimal formatting. While technically valid, the G format specifier typically only needs precision up to G29 for extreme cases. For recipe ingredient amounts, a more reasonable precision like G (default 7-15 significant digits) or an explicit format like 0.## would be clearer and more appropriate for typical cooking measurements. The G29 format may produce unnecessarily long decimal representations.
| ? $"{ingredient.Amount:G29} {ingredient.Name}" | |
| : $"{ingredient.Amount:G29} {ingredient.Unit} {ingredient.Name}").ToList(); | |
| ? $"{ingredient.Amount:0.##} {ingredient.Name}" | |
| : $"{ingredient.Amount:0.##} {ingredient.Unit} {ingredient.Name}").ToList(); |
There was a problem hiding this comment.
Fixed in 19ba997 — changed format specifier from G29 to 0.## for cleaner decimal representation in shopping list item names.
| <img | ||
| src={image.url ?? ""} |
There was a problem hiding this comment.
The image URL is used directly in the src attribute without validation for URL schemes. Malicious users could potentially provide javascript: or data: URLs that could lead to XSS attacks. While React escapes attributes, it's safer to validate that URLs use safe schemes (http/https) on the backend or implement Content Security Policy (CSP) headers to block dangerous URL schemes.
There was a problem hiding this comment.
Fixed in 19ba997 — added an isSafeUrl() helper that validates the http:///https:// scheme before using any URL in <img src> or <a href> attributes. Unsafe URLs are replaced with an empty string or #.
| [Range(0, double.MaxValue, ErrorMessage = "Amount must be a non-negative number.")] | ||
| decimal Amount, |
There was a problem hiding this comment.
The validation allows an amount of 0 for ingredients using [Range(0, double.MaxValue)]. This means recipes can have ingredients with 0 quantity, which doesn't make practical sense. Consider changing the minimum value to a small positive number (e.g., 0.001) or using [Range(0.001, double.MaxValue)] to ensure meaningful quantities.
There was a problem hiding this comment.
Fixed in 19ba997 — changed [Range(0, double.MaxValue)] to [Range(0.001, double.MaxValue)] on CreateRecipeIngredientRequest.Amount so zero quantities are rejected.
| [StringLength(500, ErrorMessage = "Link must be at most 500 characters.")] | ||
| string? Link, |
There was a problem hiding this comment.
The Link field for recipes lacks proper URL format validation. While there's a length constraint, there's no validation to ensure the string is actually a valid URL. Consider adding a [Url] validation attribute to ensure users provide valid URLs and maintain data integrity.
There was a problem hiding this comment.
Fixed in 19ba997 — added [Url(ErrorMessage = "Link must be a valid URL.")] to the Link field in CreateRecipeRequest.
| <a | ||
| href={recipe.link} |
There was a problem hiding this comment.
The recipe link URL is used directly in an anchor tag's href attribute without validation for URL schemes. Malicious users could provide javascript: URLs that could lead to XSS attacks when clicked. While the rel="noopener noreferrer" mitigates some risks, it's safer to validate that URLs use safe schemes (http/https) on the backend or client-side before rendering.
There was a problem hiding this comment.
Fixed in 19ba997 — the isSafeUrl() helper validates the http:///https:// scheme before the link is rendered in <a href>. The backend [Url] attribute also now rejects non-URL values at the API level.
| using System.ComponentModel.DataAnnotations; | ||
| using Anything.API.Data; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using MinimalApis.Extensions.Binding; | ||
|
|
||
| namespace Anything.API.Endpoints; | ||
|
|
||
| public static class RecipeEndpoints | ||
| { | ||
| private const string RecipeNotFound = "Recipe not found."; | ||
| private const string IngredientNotFound = "Ingredient not found."; | ||
| private const string StepNotFound = "Step not found."; | ||
| private const string ImageNotFound = "Image not found."; | ||
|
|
||
| public static void MapRecipeEndpoints(this IEndpointRouteBuilder app) | ||
| { | ||
| var group = app.MapGroup("/api/recipes"); | ||
|
|
||
| group.MapGet("/", async (ApplicationDbContext db) => | ||
| { | ||
| return await db.Recipes | ||
| .Where(r => r.DeletedOn == null) | ||
| .ToListAsync(); | ||
| }) | ||
| .WithName("GetRecipes") | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapGet("/{id}", async (int id, ApplicationDbContext db) => | ||
| { | ||
| return await db.Recipes.FindAsync(id) is Recipe recipe && recipe.DeletedOn == null | ||
| ? Results.Ok(recipe) | ||
| : Results.NotFound(RecipeNotFound); | ||
| }) | ||
| .WithName("GetRecipeById") | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapPost("/", async (CreateRecipeRequest request, ApplicationDbContext db) => | ||
| { | ||
| var recipe = new Recipe | ||
| { | ||
| Name = request.Name, | ||
| Link = request.Link, | ||
| Notes = request.Notes | ||
| }; | ||
|
|
||
| db.Recipes.Add(recipe); | ||
| await db.SaveChangesAsync(); | ||
| return Results.Created($"/api/recipes/{recipe.Id}", recipe); | ||
| }) | ||
| .WithName("CreateRecipe") | ||
| .WithParameterValidation() | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapPut("/{id}", async (int id, UpdateRecipeRequest request, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| recipe.Name = request.Name; | ||
| recipe.Link = request.Link; | ||
| recipe.Notes = request.Notes; | ||
| recipe.ModifiedOn = DateTime.UtcNow; | ||
|
|
||
| await db.SaveChangesAsync(); | ||
| return Results.NoContent(); | ||
| }) | ||
| .WithName("UpdateRecipe") | ||
| .WithParameterValidation() | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapDelete("/{id}", async (int id, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| recipe.DeletedOn = DateTime.UtcNow; | ||
| await db.SaveChangesAsync(); | ||
| return Results.NoContent(); | ||
| }) | ||
| .WithName("DeleteRecipe") | ||
| .RequireAuthorization(); | ||
|
|
||
| // Ingredients | ||
| group.MapGet("/{id}/ingredients", async (int id, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| var ingredients = await db.RecipeIngredients | ||
| .Where(i => i.RecipeId == id && i.DeletedOn == null) | ||
| .ToListAsync(); | ||
| return Results.Ok(ingredients); | ||
| }) | ||
| .WithName("GetRecipeIngredients") | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapPost("/{id}/ingredients", async (int id, CreateRecipeIngredientRequest request, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| var ingredient = new RecipeIngredient | ||
| { | ||
| RecipeId = id, | ||
| Name = request.Name, | ||
| Amount = request.Amount, | ||
| Unit = request.Unit, | ||
| Group = request.Group | ||
| }; | ||
|
|
||
| db.RecipeIngredients.Add(ingredient); | ||
| await db.SaveChangesAsync(); | ||
| return Results.Created($"/api/recipes/{id}/ingredients/{ingredient.Id}", ingredient); | ||
| }) | ||
| .WithName("AddRecipeIngredient") | ||
| .WithParameterValidation() | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapPut("/{id}/ingredients/{ingredientId}", async (int id, int ingredientId, UpdateRecipeIngredientRequest request, ApplicationDbContext db) => | ||
| { | ||
| var ingredient = await db.RecipeIngredients.FindAsync(ingredientId); | ||
| if (ingredient is null || ingredient.DeletedOn != null || ingredient.RecipeId != id) | ||
| return Results.NotFound(IngredientNotFound); | ||
|
|
||
| ingredient.Name = request.Name; | ||
| ingredient.Amount = request.Amount; | ||
| ingredient.Unit = request.Unit; | ||
| ingredient.Group = request.Group; | ||
| ingredient.ModifiedOn = DateTime.UtcNow; | ||
|
|
||
| await db.SaveChangesAsync(); | ||
| return Results.NoContent(); | ||
| }) | ||
| .WithName("UpdateRecipeIngredient") | ||
| .WithParameterValidation() | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapDelete("/{id}/ingredients/{ingredientId}", async (int id, int ingredientId, ApplicationDbContext db) => | ||
| { | ||
| var ingredient = await db.RecipeIngredients.FindAsync(ingredientId); | ||
| if (ingredient is null || ingredient.DeletedOn != null || ingredient.RecipeId != id) | ||
| return Results.NotFound(IngredientNotFound); | ||
|
|
||
| ingredient.DeletedOn = DateTime.UtcNow; | ||
| await db.SaveChangesAsync(); | ||
| return Results.NoContent(); | ||
| }) | ||
| .WithName("DeleteRecipeIngredient") | ||
| .RequireAuthorization(); | ||
|
|
||
| // Steps | ||
| group.MapGet("/{id}/steps", async (int id, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| var steps = await db.RecipeSteps | ||
| .Where(s => s.RecipeId == id && s.DeletedOn == null) | ||
| .OrderBy(s => s.Order) | ||
| .ToListAsync(); | ||
| return Results.Ok(steps); | ||
| }) | ||
| .WithName("GetRecipeSteps") | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapPost("/{id}/steps", async (int id, CreateRecipeStepRequest request, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| var step = new RecipeStep | ||
| { | ||
| RecipeId = id, | ||
| Text = request.Text, | ||
| Order = request.Order | ||
| }; | ||
|
|
||
| db.RecipeSteps.Add(step); | ||
| await db.SaveChangesAsync(); | ||
| return Results.Created($"/api/recipes/{id}/steps/{step.Id}", step); | ||
| }) | ||
| .WithName("AddRecipeStep") | ||
| .WithParameterValidation() | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapPut("/{id}/steps/{stepId}", async (int id, int stepId, UpdateRecipeStepRequest request, ApplicationDbContext db) => | ||
| { | ||
| var step = await db.RecipeSteps.FindAsync(stepId); | ||
| if (step is null || step.DeletedOn != null || step.RecipeId != id) | ||
| return Results.NotFound(StepNotFound); | ||
|
|
||
| step.Text = request.Text; | ||
| step.Order = request.Order; | ||
| step.ModifiedOn = DateTime.UtcNow; | ||
|
|
||
| await db.SaveChangesAsync(); | ||
| return Results.NoContent(); | ||
| }) | ||
| .WithName("UpdateRecipeStep") | ||
| .WithParameterValidation() | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapDelete("/{id}/steps/{stepId}", async (int id, int stepId, ApplicationDbContext db) => | ||
| { | ||
| var step = await db.RecipeSteps.FindAsync(stepId); | ||
| if (step is null || step.DeletedOn != null || step.RecipeId != id) | ||
| return Results.NotFound(StepNotFound); | ||
|
|
||
| step.DeletedOn = DateTime.UtcNow; | ||
| await db.SaveChangesAsync(); | ||
| return Results.NoContent(); | ||
| }) | ||
| .WithName("DeleteRecipeStep") | ||
| .RequireAuthorization(); | ||
|
|
||
| // Images | ||
| group.MapGet("/{id}/images", async (int id, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| var images = await db.RecipeImages | ||
| .Where(i => i.RecipeId == id && i.DeletedOn == null) | ||
| .ToListAsync(); | ||
| return Results.Ok(images); | ||
| }) | ||
| .WithName("GetRecipeImages") | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapPost("/{id}/images", async (int id, CreateRecipeImageRequest request, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| var image = new RecipeImage | ||
| { | ||
| RecipeId = id, | ||
| Url = request.Url | ||
| }; | ||
|
|
||
| db.RecipeImages.Add(image); | ||
| await db.SaveChangesAsync(); | ||
| return Results.Created($"/api/recipes/{id}/images/{image.Id}", image); | ||
| }) | ||
| .WithName("AddRecipeImage") | ||
| .WithParameterValidation() | ||
| .RequireAuthorization(); | ||
|
|
||
| group.MapDelete("/{id}/images/{imageId}", async (int id, int imageId, ApplicationDbContext db) => | ||
| { | ||
| var image = await db.RecipeImages.FindAsync(imageId); | ||
| if (image is null || image.DeletedOn != null || image.RecipeId != id) | ||
| return Results.NotFound(ImageNotFound); | ||
|
|
||
| image.DeletedOn = DateTime.UtcNow; | ||
| await db.SaveChangesAsync(); | ||
| return Results.NoContent(); | ||
| }) | ||
| .WithName("DeleteRecipeImage") | ||
| .RequireAuthorization(); | ||
|
|
||
| // Add ingredients to shopping list | ||
| group.MapPost("/{id}/add-to-shopping-list", async (int id, AddToShoppingListRequest request, ApplicationDbContext db) => | ||
| { | ||
| var recipe = await db.Recipes.FindAsync(id); | ||
| if (recipe is null || recipe.DeletedOn != null) | ||
| return Results.NotFound(RecipeNotFound); | ||
|
|
||
| var shoppingList = await db.ShoppingLists.FindAsync(request.ShoppingListId); | ||
| if (shoppingList is null || shoppingList.DeletedOn != null) | ||
| return Results.NotFound("Shopping list not found."); | ||
|
|
||
| var ingredients = await db.RecipeIngredients | ||
| .Where(i => i.RecipeId == id && i.DeletedOn == null) | ||
| .ToListAsync(); | ||
|
|
||
| var itemNames = ingredients.Select(ingredient => string.IsNullOrWhiteSpace(ingredient.Unit) | ||
| ? $"{ingredient.Amount:G29} {ingredient.Name}" | ||
| : $"{ingredient.Amount:G29} {ingredient.Unit} {ingredient.Name}").ToList(); | ||
|
|
||
| var itemNamesLower = itemNames.Select(n => n.ToLower()).ToHashSet(); | ||
| var existingRecommendations = await db.ShoppingListRecommendations | ||
| .Where(r => r.DeletedOn == null && itemNamesLower.Contains(r.Name.ToLower())) | ||
| .Select(r => r.Name.ToLower()) | ||
| .ToHashSetAsync(); | ||
|
|
||
| foreach (var itemName in itemNames) | ||
| { | ||
| db.ShoppingListItems.Add(new ShoppingListItem | ||
| { | ||
| ShoppingListId = request.ShoppingListId, | ||
| Name = itemName | ||
| }); | ||
|
|
||
| var nameNormalized = itemName.Trim(); | ||
| if (!existingRecommendations.Contains(nameNormalized.ToLower())) | ||
| { | ||
| db.ShoppingListRecommendations.Add(new ShoppingListRecommendation | ||
| { | ||
| Name = nameNormalized, | ||
| IsApproved = false | ||
| }); | ||
| existingRecommendations.Add(nameNormalized.ToLower()); | ||
| } | ||
| } | ||
|
|
||
| await db.SaveChangesAsync(); | ||
| return Results.NoContent(); | ||
| }) | ||
| .WithName("AddRecipeIngredientsToShoppingList") | ||
| .WithParameterValidation() | ||
| .RequireAuthorization(); | ||
| } |
There was a problem hiding this comment.
The codebase has comprehensive integration tests for other endpoints (e.g., InventoryBoxEndpointTests, ShoppingListRecommendationEndpointTests), but there are no integration tests for the new RecipeEndpoints. Consider adding integration tests following the pattern established in tests/Anything.API.IntegrationTests/ to ensure the Recipe API endpoints work correctly, including nested resources (ingredients, steps, images) and the shopping list integration functionality.
There was a problem hiding this comment.
Added RecipeEndpointTests.cs in 19ba997 with 30 integration tests following the existing ShoppingListRecommendationEndpointTests pattern (raw HttpClient). Tests cover all endpoints: recipe CRUD, ingredients (including 0/negative amount rejection and [Url] rejection for images/links), steps, images, and the add-to-shopping-list action (including 404 for missing recipe/list).
| [Range(0, double.MaxValue, ErrorMessage = "Amount must be a non-negative number.")] | ||
| decimal Amount, |
There was a problem hiding this comment.
The validation allows an amount of 0 for ingredients using [Range(0, double.MaxValue)]. This means recipes can have ingredients with 0 quantity, which doesn't make practical sense. Consider changing the minimum value to a small positive number (e.g., 0.001) or using [Range(0.001, double.MaxValue)] to ensure meaningful quantities.
There was a problem hiding this comment.
Fixed in 19ba997 — changed [Range(0, double.MaxValue)] to [Range(0.001, double.MaxValue)] on UpdateRecipeIngredientRequest.Amount as well.
| } | ||
| } | ||
|
|
||
| await db.SaveChangesAsync(); |
There was a problem hiding this comment.
There's a potential database constraint violation issue here. The ShoppingListRecommendation table has a unique constraint on the Name column (line 128 in ApplicationDbContext.cs). When multiple ingredients are added in a loop and recommendations are created, a race condition could occur if the same recommendation name appears multiple times in the itemNames list. While the code maintains an in-memory set (existingRecommendations) to prevent duplicates within this request, it should wrap the SaveChangesAsync in a try-catch to handle potential unique constraint violations gracefully, or query for existing recommendations by all itemNames upfront before the loop.
| await db.SaveChangesAsync(); | |
| try | |
| { | |
| await db.SaveChangesAsync(); | |
| } | |
| catch (DbUpdateException) | |
| { | |
| // Handle potential unique constraint violations (e.g., on ShoppingListRecommendation.Name) | |
| return Results.Problem("A database error occurred while saving the shopping list items and recommendations."); | |
| } |
There was a problem hiding this comment.
Fixed in 19ba997 — wrapped SaveChangesAsync in try-catch (DbUpdateException) and return Results.Problem(...) on failure. The in-memory existingRecommendations set still prevents duplicates within a single request; the try-catch handles any edge-case race conditions at the DB layer.
|
|
||
| public record CreateRecipeImageRequest( | ||
| [Required(ErrorMessage = "Url is required.")] | ||
| [StringLength(1000, MinimumLength = 1, ErrorMessage = "Url must be between 1 and 1000 characters.")] |
There was a problem hiding this comment.
The URL field for recipe images lacks proper URL format validation. While there's a length constraint, there's no validation to ensure the string is actually a valid URL. Consider adding a [Url] validation attribute to ensure users provide valid URLs, preventing broken image links and potential issues.
| [StringLength(1000, MinimumLength = 1, ErrorMessage = "Url must be between 1 and 1000 characters.")] | |
| [StringLength(1000, MinimumLength = 1, ErrorMessage = "Url must be between 1 and 1000 characters.")] | |
| [Url(ErrorMessage = "Url must be a valid URL.")] |
There was a problem hiding this comment.
Fixed in 19ba997 — added [Url(ErrorMessage = "Url must be a valid URL.")] to CreateRecipeImageRequest.Url. The integration tests also verify that an invalid URL returns HTTP 400.
| [StringLength(500, ErrorMessage = "Link must be at most 500 characters.")] | ||
| string? Link, |
There was a problem hiding this comment.
The Link field for recipes lacks proper URL format validation. While there's a length constraint, there's no validation to ensure the string is actually a valid URL. Consider adding a [Url] validation attribute to ensure users provide valid URLs and maintain data integrity.
There was a problem hiding this comment.
Fixed in 19ba997 — added [Url(ErrorMessage = "Link must be a valid URL.")] to the Link field in UpdateRecipeRequest as well.
inur93
left a comment
There was a problem hiding this comment.
Please solve comments and make sure tests are green and coverage is over 80%
… integration tests Co-authored-by: inur93 <5747088+inur93@users.noreply.github.com>
inur93
left a comment
There was a problem hiding this comment.
Please solve build and test issues and make sure frontend coverage is over 80%
- Create src/app/recipes/page.test.tsx with 14 tests (loading, error, empty state, list display, create, delete, navigation, admin panel) - Create src/app/recipes/[id]/page.test.tsx with 20 tests (loading, error, recipe details, edit mode, ingredients CRUD, steps CRUD, images CRUD, back navigation, add-to-shopping-list) - Extend src/hooks/useRecipes.test.tsx with 9 new tests covering useRecipe, useUpdateRecipe, useUpdateRecipeIngredient, useDeleteRecipeIngredient, useUpdateRecipeStep, useDeleteRecipeStep, useAddRecipeImage, useDeleteRecipeImage Coverage improvements: - useRecipes.ts: 53.8% -> 100% - recipes/page.tsx: 0% -> 95.13% - recipes/[id]/page.tsx: 0% -> 87.59% - Overall statements: 64.12% -> 88.57% Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixed in Backend build: The CI failure ( Frontend coverage: Added 45 new tests across 3 files:
Overall coverage: 88.57% (was 64.12%), all 204 tests pass. |
inur93
left a comment
There was a problem hiding this comment.
Backend Tests are failing
…isolation Co-authored-by: inur93 <5747088+inur93@users.noreply.github.com>
Fixed in |
|
|





bin\Debugbuild artifacts accidentally committed to gitRecipes,RecipeIngredients,RecipeSteps,RecipeImagestoResetDatabaseAsync()inAnythingApiFactory.cs— tests were failing because recipe data created by one test persisted into the next testOriginal prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.