UI/logic updates, tests added, backend updated
This commit is contained in:
@@ -35,7 +35,6 @@ public class RecipeController : ControllerBase
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
[HttpPost("save")]
|
[HttpPost("save")]
|
||||||
public async Task<IActionResult> SaveRecipe([FromBody] RecipeResponseDto recipeDto)
|
public async Task<IActionResult> SaveRecipe([FromBody] RecipeResponseDto recipeDto)
|
||||||
{
|
{
|
||||||
@@ -49,7 +48,7 @@ public class RecipeController : ControllerBase
|
|||||||
var recipe = new Recipe
|
var recipe = new Recipe
|
||||||
{
|
{
|
||||||
Title = recipeDto.Title,
|
Title = recipeDto.Title,
|
||||||
Icon = recipeDto.Icon,
|
ImageUrl = recipeDto.ImageUrl,
|
||||||
Ingredients = recipeDto.Ingredients,
|
Ingredients = recipeDto.Ingredients,
|
||||||
Instructions = recipeDto.Instructions,
|
Instructions = recipeDto.Instructions,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -62,7 +61,6 @@ public class RecipeController : ControllerBase
|
|||||||
return Ok(new { message = "Recipe saved to your collection!" });
|
return Ok(new { message = "Recipe saved to your collection!" });
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
[HttpPut("update/{id}")]
|
[HttpPut("update/{id}")]
|
||||||
public async Task<IActionResult> UpdateRecipe(int id, [FromBody] Recipe updatedRecipe)
|
public async Task<IActionResult> UpdateRecipe(int id, [FromBody] Recipe updatedRecipe)
|
||||||
{
|
{
|
||||||
@@ -79,15 +77,19 @@ public class RecipeController : ControllerBase
|
|||||||
existingRecipe.Title = updatedRecipe.Title;
|
existingRecipe.Title = updatedRecipe.Title;
|
||||||
existingRecipe.Ingredients = updatedRecipe.Ingredients;
|
existingRecipe.Ingredients = updatedRecipe.Ingredients;
|
||||||
existingRecipe.Instructions = updatedRecipe.Instructions;
|
existingRecipe.Instructions = updatedRecipe.Instructions;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(updatedRecipe.ImageUrl))
|
||||||
|
{
|
||||||
|
existingRecipe.ImageUrl = updatedRecipe.ImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new { message = "Recipe updated successfully!" });
|
return Ok(new { message = "Recipe updated successfully!" });
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
[HttpGet("my-collection")]
|
[HttpGet("my-collection")]
|
||||||
public async Task<ActionResult<IEnumerable<Recipe>>> GetMyRecipes()
|
public async Task<ActionResult<IEnumerable<Recipe>>> GetMyRecipes()
|
||||||
{
|
{
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace Seasoned.Backend.DTOs;
|
|||||||
public class RecipeResponseDto
|
public class RecipeResponseDto
|
||||||
{
|
{
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Icon { get; set; } = "mdi-silverware-fork-knife";
|
public string? ImageUrl { get; set; }
|
||||||
public List<string> Ingredients { get; set; } = new();
|
public List<string> Ingredients { get; set; } = new();
|
||||||
public List<string> Instructions { get; set; } = new();
|
public List<string> Instructions { get; set; } = new();
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ using Seasoned.Backend.Data;
|
|||||||
namespace Seasoned.Backend.Migrations
|
namespace Seasoned.Backend.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20260311160009_AddRecipeFields")]
|
[Migration("20260318044626_ChangeIconToImageUrl")]
|
||||||
partial class AddRecipeFields
|
partial class ChangeIconToImageUrl
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -234,8 +234,7 @@ namespace Seasoned.Backend.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Icon")
|
b.Property<string>("ImageUrl")
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.PrimitiveCollection<List<string>>("Ingredients")
|
b.PrimitiveCollection<List<string>>("Ingredients")
|
||||||
@@ -8,7 +8,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|||||||
namespace Seasoned.Backend.Migrations
|
namespace Seasoned.Backend.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddRecipeFields : Migration
|
public partial class ChangeIconToImageUrl : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
@@ -168,7 +168,7 @@ namespace Seasoned.Backend.Migrations
|
|||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
Title = table.Column<string>(type: "text", nullable: false),
|
Title = table.Column<string>(type: "text", nullable: false),
|
||||||
Icon = table.Column<string>(type: "text", nullable: false),
|
ImageUrl = table.Column<string>(type: "text", nullable: true),
|
||||||
Ingredients = table.Column<List<string>>(type: "text[]", nullable: false),
|
Ingredients = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||||
Instructions = table.Column<List<string>>(type: "text[]", nullable: false),
|
Instructions = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
@@ -231,8 +231,7 @@ namespace Seasoned.Backend.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Icon")
|
b.Property<string>("ImageUrl")
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.PrimitiveCollection<List<string>>("Ingredients")
|
b.PrimitiveCollection<List<string>>("Ingredients")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace Seasoned.Backend.Models;
|
|||||||
public class Recipe {
|
public class Recipe {
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Icon { get; set; } = "mdi-silverware-fork-knife";
|
public string? ImageUrl { get; set; }
|
||||||
public List<string> Ingredients { get; set; } = new();
|
public List<string> Ingredients { get; set; } = new();
|
||||||
public List<string> Instructions { get; set; } = new();
|
public List<string> Instructions { get; set; } = new();
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Seasoned.Backend.Services;
|
namespace Seasoned.Backend.Services;
|
||||||
|
|
||||||
@@ -18,6 +19,11 @@ public class RecipeService : IRecipeService
|
|||||||
|
|
||||||
public async Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image)
|
public async Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image)
|
||||||
{
|
{
|
||||||
|
if (image == null || image.Length == 0)
|
||||||
|
{
|
||||||
|
return new RecipeResponseDto { Title = "Error: No image provided" };
|
||||||
|
}
|
||||||
|
|
||||||
var googleAI = new GoogleAI(_apiKey);
|
var googleAI = new GoogleAI(_apiKey);
|
||||||
|
|
||||||
var model = googleAI.GenerativeModel("gemini-3.1-flash-lite-preview");
|
var model = googleAI.GenerativeModel("gemini-3.1-flash-lite-preview");
|
||||||
@@ -27,21 +33,15 @@ public class RecipeService : IRecipeService
|
|||||||
var base64Image = Convert.ToBase64String(ms.ToArray());
|
var base64Image = Convert.ToBase64String(ms.ToArray());
|
||||||
|
|
||||||
var prompt = @"Extract the recipe details from this image.
|
var prompt = @"Extract the recipe details from this image.
|
||||||
|
Return ONLY a raw JSON string.
|
||||||
RULES FOR THE 'icon' FIELD:
|
|
||||||
1. Select a valid 'Material Design Icon' name (e.g., 'mdi-pasta', 'mdi-bread-slice', 'mdi-muffin', 'mdi-pizza').
|
|
||||||
2. If the recipe type is ambiguous or you cannot find a specific matching icon, you MUST return 'mdi-silverware-fork-knife'.
|
|
||||||
|
|
||||||
IMPORTANT: Return ONLY a raw JSON string.
|
|
||||||
DO NOT include markdown formatting.
|
DO NOT include markdown formatting.
|
||||||
JSON structure:
|
JSON structure:
|
||||||
{
|
{
|
||||||
""title"": ""string"",
|
""title"": ""string"",
|
||||||
""icon"": ""string"",
|
""ingredients"": [""string"", ""string""],
|
||||||
""ingredients"": [""string"", ""string""],
|
""instructions"": [""string"", ""string""]
|
||||||
""instructions"": [""string"", ""string""]
|
|
||||||
}";
|
}";
|
||||||
|
|
||||||
var generationConfig = new GenerationConfig {
|
var generationConfig = new GenerationConfig {
|
||||||
ResponseMimeType = "application/json",
|
ResponseMimeType = "application/json",
|
||||||
Temperature = 0.1f
|
Temperature = 0.1f
|
||||||
@@ -54,24 +54,21 @@ public class RecipeService : IRecipeService
|
|||||||
var response = await model.GenerateContent(request);
|
var response = await model.GenerateContent(request);
|
||||||
string rawText = response.Text?.Trim() ?? "";
|
string rawText = response.Text?.Trim() ?? "";
|
||||||
|
|
||||||
int start = rawText.IndexOf('{');
|
string cleanJson = CleanJsonResponse(rawText);
|
||||||
int end = rawText.LastIndexOf('}');
|
|
||||||
|
|
||||||
if (start == -1 || end == -1)
|
if (string.IsNullOrEmpty(cleanJson))
|
||||||
{
|
{
|
||||||
return new RecipeResponseDto { Title = "Error" };
|
return new RecipeResponseDto { Title = "Error: Invalid AI Response" };
|
||||||
}
|
}
|
||||||
|
|
||||||
string cleanJson = rawText.Substring(start, (end - start) + 1);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
var result = JsonSerializer.Deserialize<RecipeResponseDto>(cleanJson, options);
|
var result = JsonSerializer.Deserialize<RecipeResponseDto>(cleanJson, options);
|
||||||
|
|
||||||
if (result != null && string.IsNullOrEmpty(result.Icon))
|
if (result != null)
|
||||||
{
|
{
|
||||||
result.Icon = "mdi-silverware-fork-knife";
|
result.ImageUrl = $"data:{image.ContentType};base64,{base64Image}";
|
||||||
}
|
}
|
||||||
|
|
||||||
return result ?? new RecipeResponseDto { Title = "Empty Response" };
|
return result ?? new RecipeResponseDto { Title = "Empty Response" };
|
||||||
@@ -79,8 +76,6 @@ public class RecipeService : IRecipeService
|
|||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Raw AI Output: {rawText}");
|
Console.WriteLine($"Raw AI Output: {rawText}");
|
||||||
Console.WriteLine($"Failed to parse JSON: {ex.Message}");
|
|
||||||
|
|
||||||
return new RecipeResponseDto { Title = "Parsing Error" };
|
return new RecipeResponseDto { Title = "Parsing Error" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +103,6 @@ public class RecipeService : IRecipeService
|
|||||||
""reply"": ""A friendly, thematic response from the Chef."",
|
""reply"": ""A friendly, thematic response from the Chef."",
|
||||||
""recipe"": {
|
""recipe"": {
|
||||||
""title"": ""string"",
|
""title"": ""string"",
|
||||||
""icon"": ""string (must be a valid mdi- icon name)"",
|
|
||||||
""ingredients"": [""string"", ""string""],
|
""ingredients"": [""string"", ""string""],
|
||||||
""instructions"": [""string"", ""string""]
|
""instructions"": [""string"", ""string""]
|
||||||
}
|
}
|
||||||
@@ -125,11 +119,15 @@ public class RecipeService : IRecipeService
|
|||||||
var request = new GenerateContentRequest(fullPrompt, generationConfig);
|
var request = new GenerateContentRequest(fullPrompt, generationConfig);
|
||||||
var response = await model.GenerateContent(request);
|
var response = await model.GenerateContent(request);
|
||||||
|
|
||||||
|
string rawText = response.Text ?? "";
|
||||||
|
string jsonToParse = CleanJsonResponse(rawText);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(jsonToParse))
|
||||||
|
return new ChefConsultResponseDto { Reply = "The Chef is at a loss for words. Try rephrasing?" };
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
string jsonToParse = response.Text ?? "{ \"reply\": \"The chef is speechless. Try again?\" }";
|
|
||||||
|
|
||||||
var result = JsonSerializer.Deserialize<ChefConsultResponseDto>(jsonToParse, options);
|
var result = JsonSerializer.Deserialize<ChefConsultResponseDto>(jsonToParse, options);
|
||||||
|
|
||||||
return result ?? new ChefConsultResponseDto { Reply = "Chef is a bit confused!" };
|
return result ?? new ChefConsultResponseDto { Reply = "Chef is a bit confused!" };
|
||||||
@@ -139,4 +137,12 @@ public class RecipeService : IRecipeService
|
|||||||
return new ChefConsultResponseDto { Reply = "The kitchen is a mess right now. Try again?" };
|
return new ChefConsultResponseDto { Reply = "The kitchen is a mess right now. Try again?" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal string CleanJsonResponse(string rawText)
|
||||||
|
{
|
||||||
|
int start = rawText.IndexOf('{');
|
||||||
|
int end = rawText.LastIndexOf('}');
|
||||||
|
if (start == -1 || end == -1) return string.Empty;
|
||||||
|
return rawText.Substring(start, (end - start) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +190,17 @@ html, body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-success-btn {
|
||||||
|
opacity: 1 !important;
|
||||||
|
color: white !important;
|
||||||
|
cursor: default;
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-swing {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.print-btn {
|
.print-btn {
|
||||||
background-color: #3b4e1e !important;
|
background-color: #3b4e1e !important;
|
||||||
color: #f4e4bc !important;
|
color: #f4e4bc !important;
|
||||||
@@ -208,89 +219,82 @@ html, body {
|
|||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
@page {
|
@page {
|
||||||
margin: 0.5in 0.75in !important;
|
margin: 0 !important;
|
||||||
|
size: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-application,
|
body {
|
||||||
.v-application__wrap,
|
|
||||||
main.v-main,
|
|
||||||
.v-container {
|
|
||||||
padding-top: 0 !important;
|
|
||||||
margin-top: 0 !important;
|
|
||||||
position: static !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-content {
|
|
||||||
margin-top: -20px !important;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-container,
|
|
||||||
.v-app-bar,
|
|
||||||
.no-print,
|
|
||||||
.separator,
|
|
||||||
.v-divider,
|
|
||||||
button,
|
|
||||||
.v-btn {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body, .recipe-bg, .landing-page, .recipe-card {
|
|
||||||
background: white !important;
|
background: white !important;
|
||||||
background-image: none !important;
|
padding: 0.75in !important;
|
||||||
box-shadow: none !important;
|
margin: 0 !important;
|
||||||
color: black !important;
|
width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container, .v-app-bar, .no-print, .separator, .v-divider, .recipe-description, button, .v-btn {
|
||||||
|
display: none !important;
|
||||||
|
height: 0 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-row,
|
.v-application, .v-application__wrap, main.v-main, .v-container, .recipe-card {
|
||||||
.v-col,
|
height: auto !important;
|
||||||
.v-container,
|
min-height: 0 !important;
|
||||||
.v-card,
|
margin: 0 !important;
|
||||||
.recipe-card,
|
padding: 0 !important;
|
||||||
div[class*="v-col"] {
|
position: static !important;
|
||||||
border: none !important;
|
}
|
||||||
outline: none !important;
|
|
||||||
|
html, body {
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-application {
|
||||||
|
display: block !important; /* Prevents flex-grow from creating a 2nd page */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Tighten the Footer (Just in case) */
|
||||||
|
footer, .brand-subtitle {
|
||||||
|
page-break-after: avoid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card,
|
||||||
|
.recipe-card,
|
||||||
|
.v-application,
|
||||||
|
.v-application__wrap,
|
||||||
|
[class*="elevation-"] {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. Specific fix for Vuetify's "thin" borders */
|
|
||||||
.v-border-thin,
|
|
||||||
.border,
|
|
||||||
.border-sm {
|
|
||||||
border-width: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-border-thin,
|
|
||||||
.border,
|
|
||||||
.border-sm {
|
|
||||||
border-width: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.text-center {
|
header.text-center {
|
||||||
margin-top: 0 !important;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
margin-bottom: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.text-center img,
|
|
||||||
.v-img,
|
|
||||||
[class*="v-img"] {
|
|
||||||
max-height: 60px !important;
|
|
||||||
margin-bottom: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-subtitle {
|
|
||||||
margin-bottom: 5px !important;
|
margin-bottom: 5px !important;
|
||||||
font-size: 0.7rem !important;
|
}
|
||||||
|
|
||||||
|
.recipe-content {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.text-center img, .v-img, [class*="v-img"] {
|
||||||
|
max-height: 65px !important;
|
||||||
|
margin-bottom: 2px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-title {
|
.recipe-title {
|
||||||
margin-top: 0 !important;
|
margin-bottom: 40px !important;
|
||||||
padding-top: 0 !important;
|
font-size: 1.6rem !important;
|
||||||
margin-bottom: 45px !important;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-weight: bold !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-content .v-row {
|
.recipe-content .v-row {
|
||||||
@@ -298,53 +302,41 @@ html, body {
|
|||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
gap: 0.4in !important;
|
gap: 0.5in !important;
|
||||||
align-items: flex-start !important;
|
align-items: flex-start !important;
|
||||||
margin-top: 0 !important;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-content .v-row > div:first-child {
|
.recipe-content .v-row > div:first-child {
|
||||||
flex: 0 0 35% !important;
|
flex: 0 0 33% !important;
|
||||||
width: 35% !important;
|
max-width: 33% !important;
|
||||||
max-width: 35% !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-content .v-row > div:last-child {
|
.recipe-content .v-row > div:last-child {
|
||||||
flex: 0 0 60% !important;
|
flex: 0 0 62% !important;
|
||||||
width: 60% !important;
|
max-width: 62% !important;
|
||||||
max-width: 60% !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
margin-bottom: 12px !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
border-bottom: none !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
border: none !important;
|
border: none !important;
|
||||||
box-shadow: none !important;
|
font-weight: bold !important;
|
||||||
outline: none !important;
|
margin-bottom: 15px !important;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ingredient-item, .step-text {
|
.ingredient-item, .step-text {
|
||||||
font-size: 0.95rem !important;
|
font-size: 0.95rem !important;
|
||||||
line-height: 1.3 !important;
|
line-height: 1.4 !important;
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-number {
|
|
||||||
border: none !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.instruction-step {
|
.instruction-step {
|
||||||
margin-bottom: 8px !important;
|
margin-bottom: 10px !important;
|
||||||
gap: 8px !important;
|
display: flex !important;
|
||||||
|
gap: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-content, .v-row, .v-col, * {
|
||||||
|
overflow: visible !important;
|
||||||
|
height: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-number, .ingredient-item::before {
|
.step-number, .ingredient-item::before {
|
||||||
@@ -352,12 +344,6 @@ html, body {
|
|||||||
-webkit-print-color-adjust: exact;
|
-webkit-print-color-adjust: exact;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-content, .v-row, .v-col {
|
|
||||||
overflow: visible !important;
|
|
||||||
height: auto !important;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
|
|||||||
@@ -94,10 +94,11 @@
|
|||||||
text-align: center !important;
|
text-align: center !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-textarea .v-field__input {
|
.v-textarea .v-field__input, .v-textarea textarea {
|
||||||
font-weight: 500 !important;
|
font-family: 'Libre Baskerville', serif !important;
|
||||||
color: #2c2925 !important;
|
font-size: 1.1rem !important;
|
||||||
line-height: 1.6 !important;
|
line-height: 1.6 !important;
|
||||||
|
color: #2c2925 !important
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-field-label {
|
.v-field-label {
|
||||||
@@ -105,6 +106,13 @@
|
|||||||
opacity: 0.6 !important;
|
opacity: 0.6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v-textarea .v-label,
|
||||||
|
.v-textarea .v-field-label {
|
||||||
|
font-family: 'Libre Baskerville', serif !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.v-field__outline {
|
.v-field__outline {
|
||||||
--v-field-border-opacity: 1 !important;
|
--v-field-border-opacity: 1 !important;
|
||||||
color: #d1c7b7 !important;
|
color: #d1c7b7 !important;
|
||||||
|
|||||||
@@ -4,11 +4,17 @@
|
|||||||
<v-divider class="mb-10 separator"></v-divider>
|
<v-divider class="mb-10 separator"></v-divider>
|
||||||
|
|
||||||
<h2 class="recipe-title text-center mb-4">{{ recipe.title }}</h2>
|
<h2 class="recipe-title text-center mb-4">{{ recipe.title }}</h2>
|
||||||
<p v-if="recipe.description" class="recipe-description text-center mb-16 text-italic">
|
|
||||||
{{ recipe.description }}
|
<v-img
|
||||||
</p>
|
v-if="recipe.imageUrl"
|
||||||
|
:src="recipe.imageUrl"
|
||||||
|
class="recipe-image rounded-lg mb-8 mx-auto"
|
||||||
|
elevation="2"
|
||||||
|
max-height="400"
|
||||||
|
cover
|
||||||
|
></v-img>
|
||||||
|
|
||||||
<v-row class="mt-10" no-gutters>
|
<v-row class="mt-10" density="compact">
|
||||||
<v-col cols="12" md="5" class="pe-md-10">
|
<v-col cols="12" md="5" class="pe-md-10">
|
||||||
<div class="section-header justify-center mb-6">
|
<div class="section-header justify-center mb-6">
|
||||||
<v-icon icon="mdi-spoon-sugar" class="mr-2" size="small"></v-icon>
|
<v-icon icon="mdi-spoon-sugar" class="mr-2" size="small"></v-icon>
|
||||||
@@ -45,26 +51,25 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!hasSaved"
|
class="px-12 transition-swing"
|
||||||
class="save-recipe-btn px-12"
|
|
||||||
size="large"
|
size="large"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
:loading="isSaving"
|
:loading="isSaving"
|
||||||
|
:disabled="hasSaved"
|
||||||
|
:color="hasSaved ? '#556b2f' : '#5d4a36'"
|
||||||
|
:class="hasSaved ? 'save-success-btn' : 'save-recipe-btn'"
|
||||||
@click="$emit('save')"
|
@click="$emit('save')"
|
||||||
>
|
>
|
||||||
<v-icon icon="mdi-content-save-check-outline" class="mr-2"></v-icon>
|
<template v-if="!hasSaved">
|
||||||
Save to Collection
|
<v-icon icon="mdi-content-save-check-outline" class="mr-2"></v-icon>
|
||||||
|
Save to Collection
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-icon icon="mdi-check-decagram" class="mr-2"></v-icon>
|
||||||
|
Saved in Archives
|
||||||
|
</template>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-chip
|
|
||||||
v-else
|
|
||||||
color="#556b2f"
|
|
||||||
variant="outlined"
|
|
||||||
prepend-icon="mdi-check-decagram"
|
|
||||||
class="pa-6"
|
|
||||||
>
|
|
||||||
Saved to Archives
|
|
||||||
</v-chip>
|
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@@ -75,7 +80,7 @@ import { ref } from 'vue'
|
|||||||
import '@/assets/css/app-theme.css'
|
import '@/assets/css/app-theme.css'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
//recipe: { type: Object, default: null },
|
recipe: { type: Object, default: null },
|
||||||
isSaving: { type: Boolean, default: false },
|
isSaving: { type: Boolean, default: false },
|
||||||
hasSaved: { type: Boolean, default: false }
|
hasSaved: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
@@ -87,7 +92,7 @@ const printRecipe = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mock output
|
// mock output
|
||||||
const recipe = ref({
|
/*const recipe = ref({
|
||||||
title: "Bakery-Style Lemon Blueberry Muffins",
|
title: "Bakery-Style Lemon Blueberry Muffins",
|
||||||
ingredients: [
|
ingredients: [
|
||||||
"2 cups all-purpose flour",
|
"2 cups all-purpose flour",
|
||||||
@@ -109,5 +114,5 @@ const recipe = ref({
|
|||||||
"Toss the blueberries in a teaspoon of flour, then gently fold them into the batter.",
|
"Toss the blueberries in a teaspoon of flour, then gently fold them into the batter.",
|
||||||
"Divide the batter evenly into the muffin cups and bake for 18-20 minutes until golden."
|
"Divide the batter evenly into the muffin cups and bake for 18-20 minutes until golden."
|
||||||
]
|
]
|
||||||
})
|
}) */
|
||||||
</script>
|
</script>
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<v-col cols="12" md="11">
|
<v-col cols="12" md="11">
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<div class="section-header mb-4 d-flex align-center">
|
<div class="section-header mb-4 d-flex align-center">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
|
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
|
||||||
<span>Ask the Chef</span>
|
<span>Ask the Chef</span>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
@@ -82,9 +83,39 @@ const userQuery = ref('')
|
|||||||
const chatLoading = ref(false)
|
const chatLoading = ref(false)
|
||||||
const chatMessages = ref([])
|
const chatMessages = ref([])
|
||||||
const chatDisplay = ref(null)
|
const chatDisplay = ref(null)
|
||||||
|
const router = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const hasSaved = ref(false)
|
const hasSaved = ref(false)
|
||||||
|
|
||||||
|
const isAuthenticated = async () => {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/manage/info', { credentials: 'include' })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveToCollection = async () => {
|
||||||
|
if (!recipe.value || hasSaved.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`${config.public.apiBase}api/recipe/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: recipe.value
|
||||||
|
})
|
||||||
|
|
||||||
|
hasSaved.value = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Save failed:", error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const askChef = async () => {
|
const askChef = async () => {
|
||||||
if (!userQuery.value.trim()) return
|
if (!userQuery.value.trim()) return
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,15 @@
|
|||||||
class="rounded-sm mb-4 d-flex align-center justify-center"
|
class="rounded-sm mb-4 d-flex align-center justify-center"
|
||||||
style="border: 1px solid #e8e2d6;"
|
style="border: 1px solid #e8e2d6;"
|
||||||
>
|
>
|
||||||
|
<v-img
|
||||||
|
v-if="recipe.imageUrl"
|
||||||
|
:src="recipe.imageUrl"
|
||||||
|
cover
|
||||||
|
class="recipe-thumbnail"
|
||||||
|
></v-img>
|
||||||
<v-icon
|
<v-icon
|
||||||
:icon="getRecipeIcon(recipe)"
|
v-else
|
||||||
|
icon="mdi-camera-outline"
|
||||||
size="80"
|
size="80"
|
||||||
color="#d1c7b7"
|
color="#d1c7b7"
|
||||||
></v-icon>
|
></v-icon>
|
||||||
@@ -77,6 +84,7 @@
|
|||||||
<v-dialog v-model="showDetails" max-width="800" persistent>
|
<v-dialog v-model="showDetails" max-width="800" persistent>
|
||||||
<v-card v-if="selectedRecipe" class="recipe-card pa-8">
|
<v-card v-if="selectedRecipe" class="recipe-card pa-8">
|
||||||
<v-btn
|
<v-btn
|
||||||
|
v-if="!isEditing"
|
||||||
icon="mdi-close"
|
icon="mdi-close"
|
||||||
variant="text"
|
variant="text"
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@@ -100,7 +108,7 @@
|
|||||||
<v-row justify="center" class="px-md-10">
|
<v-row justify="center" class="px-md-10">
|
||||||
<v-col cols="12" md="5" class="d-flex flex-column align-center">
|
<v-col cols="12" md="5" class="d-flex flex-column align-center">
|
||||||
<div style="width: 100%; max-width: 300px;">
|
<div style="width: 100%; max-width: 300px;">
|
||||||
<h3 class="section-header mb-4">
|
<h3 class="section-header justify-center mb-4">
|
||||||
<v-icon icon="mdi-basket-outline" class="mr-2" size="small"></v-icon>
|
<v-icon icon="mdi-basket-outline" class="mr-2" size="small"></v-icon>
|
||||||
Ingredients
|
Ingredients
|
||||||
</h3>
|
</h3>
|
||||||
@@ -127,7 +135,7 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" md="7">
|
<v-col cols="12" md="7">
|
||||||
<h3 class="section-header mb-4">
|
<h3 class="section-header justify-center mb-4">
|
||||||
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
|
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
|
||||||
Instructions
|
Instructions
|
||||||
</h3>
|
</h3>
|
||||||
@@ -178,6 +186,45 @@
|
|||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
<v-row justify="center" class="mb-4">
|
||||||
|
<v-col cols="12" class="d-flex flex-column align-center">
|
||||||
|
<v-hover v-slot="{ isHovering, props }">
|
||||||
|
<v-card
|
||||||
|
v-bind="props"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
class="rounded-lg d-flex align-center justify-center cursor-pointer position-relative"
|
||||||
|
@click="$refs.fileInput.click()"
|
||||||
|
:elevation="isHovering ? 4 : 1"
|
||||||
|
style="border: 2px dashed #d1c7b7; background: #fcfaf5;"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
v-if="selectedRecipe.imageUrl"
|
||||||
|
:src="selectedRecipe.imageUrl"
|
||||||
|
cover
|
||||||
|
class="rounded-lg"
|
||||||
|
></v-img>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isEditing && (!selectedRecipe.imageUrl || isHovering)"
|
||||||
|
class="d-flex flex-column align-center justify-center position-absolute"
|
||||||
|
style="background: rgba(255,255,255,0.7); inset: 0;"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-camera-plus" color="#556b2f" size="large"></v-icon>
|
||||||
|
<span class="brand-subtitle" style="font-size: 0.7rem;">Update Photo</span>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-hover>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInput"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleImageUpload"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
@@ -214,6 +261,17 @@ const fetchRecipes = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleImageUpload = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
selectedRecipe.value.imageUrl = e.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
const openRecipe = (recipe) => {
|
const openRecipe = (recipe) => {
|
||||||
selectedRecipe.value = { ...recipe }
|
selectedRecipe.value = { ...recipe }
|
||||||
@@ -252,25 +310,31 @@ const closeDetails = () => {
|
|||||||
|
|
||||||
const saveChanges = async () => {
|
const saveChanges = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = { ...selectedRecipe.value };
|
const payload = {
|
||||||
if (typeof payload.ingredients === 'string') {
|
...selectedRecipe.value,
|
||||||
payload.ingredients = payload.ingredients.split('\n').filter(i => i.trim());
|
ingredients: typeof selectedRecipe.value.ingredients === 'string'
|
||||||
}
|
? selectedRecipe.value.ingredients.split('\n').filter(i => i.trim())
|
||||||
if (typeof payload.instructions === 'string') {
|
: selectedRecipe.value.ingredients,
|
||||||
payload.instructions = payload.instructions.split('\n').filter(i => i.trim());
|
instructions: typeof selectedRecipe.value.instructions === 'string'
|
||||||
}
|
? selectedRecipe.value.instructions.split('\n').filter(i => i.trim())
|
||||||
|
: selectedRecipe.value.instructions
|
||||||
|
};
|
||||||
|
|
||||||
await $fetch(`${config.public.apiBase}api/recipe/update/${selectedRecipe.value.id}`, {
|
await $fetch(`${config.public.apiBase}api/recipe/update/${payload.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
credentials: 'include',
|
body: payload,
|
||||||
body: payload
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
await fetchRecipes();
|
const index = recipes.value.findIndex(r => r.id === payload.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
recipes.value[index] = { ...payload };
|
||||||
|
}
|
||||||
|
|
||||||
closeDetails();
|
closeDetails();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to update recipe:", e);
|
console.error("The kitchen ledger could not be updated:", e);
|
||||||
alert("Could not save changes.");
|
alert("Could not save changes. Please try again.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="pa-0 landing-wrapper">
|
<v-container fluid class="pa-0 landing-wrapper">
|
||||||
<v-row no-gutters justify="center" align="start" class="pt-6">
|
<v-row density="compact" justify="center" align="start" class="pt-6">
|
||||||
<v-col cols="12" class="text-center px-4">
|
<v-col cols="12" class="text-center px-4">
|
||||||
|
|
||||||
<v-card class="recipe-card pa-8 mx-auto mt-2" max-width="900">
|
<v-card class="recipe-card pa-8 mx-auto mt-2" max-width="900">
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn v-if="isLoggedIn" to="/uploader" class="mt-12 column-btn">
|
<v-btn v-if="isLoggedIn" to="/uploader" class="mt-12 column-btn">
|
||||||
Got to Uploader
|
Go to Uploader
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="4" class="text-center d-flex flex-column align-center">
|
<v-col cols="12" md="4" class="text-center d-flex flex-column align-center">
|
||||||
|
|||||||
2008
Seasoned.Frontend/package-lock.json
generated
2008
Seasoned.Frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@
|
|||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"nuxt": "^4.1.3",
|
|
||||||
"mdi": "^2.2.43",
|
"mdi": "^2.2.43",
|
||||||
|
"nuxt": "^4.1.3",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"sass": "^1.97.3",
|
"sass": "^1.97.3",
|
||||||
"vite-plugin-vuetify": "^2.1.3",
|
"vite-plugin-vuetify": "^2.1.3",
|
||||||
@@ -27,11 +27,12 @@
|
|||||||
"vuetify-nuxt-module": "^0.19.5"
|
"vuetify-nuxt-module": "^0.19.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@nuxt/test-utils": "^4.0.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"happy-dom": "^20.8.3",
|
"happy-dom": "^20.8.4",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
|
|
||||||
describe('Frontend Setup', () => {
|
|
||||||
it('checks that 1 + 1 is 2', () => {
|
|
||||||
expect(1 + 1).toBe(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
77
Seasoned.Frontend/test/ChatPage.spec.ts
Normal file
77
Seasoned.Frontend/test/ChatPage.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import ChatPage from "@/pages/chat.vue"
|
||||||
|
|
||||||
|
const vuetify = createVuetify({ components, directives })
|
||||||
|
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {} unobserve() {} disconnect() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
global.Element.prototype.scrollTo = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||||
|
public: { apiBase: 'http://localhost:5000/' }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockRouter = { push: vi.fn(), resolve: vi.fn(() => ({ href: '' })) }
|
||||||
|
vi.stubGlobal('useRouter', () => mockRouter)
|
||||||
|
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('$fetch', mockFetch)
|
||||||
|
|
||||||
|
describe('ChatPage.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
global.Element.prototype.scrollTo = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountOptions = {
|
||||||
|
global: {
|
||||||
|
plugins: [vuetify],
|
||||||
|
stubs: { RecipeDisplay: true },
|
||||||
|
provide: { 'router': mockRouter }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows the placeholder when chat is empty', () => {
|
||||||
|
const wrapper = mount(ChatPage, mountOptions)
|
||||||
|
expect(wrapper.text()).toContain('"What shall we create today?"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a user message and clears input on send', async () => {
|
||||||
|
const wrapper = mount(ChatPage, mountOptions)
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
vm.userQuery = 'How do I make a roux?'
|
||||||
|
mockFetch.mockResolvedValueOnce({ reply: 'Test', recipe: null })
|
||||||
|
|
||||||
|
await vm.askChef()
|
||||||
|
|
||||||
|
expect(vm.chatMessages[0].text).toBe('How do I make a roux?')
|
||||||
|
expect(vm.userQuery).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays the assistant reply from the .NET API', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
reply: 'A roux is equal parts flour and fat.',
|
||||||
|
recipe: null
|
||||||
|
})
|
||||||
|
const wrapper = mount(ChatPage, mountOptions)
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
vm.userQuery = 'Tell me about roux'
|
||||||
|
await vm.askChef()
|
||||||
|
expect(vm.chatMessages[1].text).toContain('equal parts flour')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error message if the API fails', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce(new Error('Backend Down'))
|
||||||
|
const wrapper = mount(ChatPage, mountOptions)
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
vm.userQuery = 'Help!'
|
||||||
|
await vm.askChef()
|
||||||
|
expect(wrapper.text()).toContain('The kitchen is currently closed')
|
||||||
|
})
|
||||||
|
})
|
||||||
113
Seasoned.Frontend/test/GalleryPage.spec.ts
Normal file
113
Seasoned.Frontend/test/GalleryPage.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import GalleryPage from "@/pages/gallery.vue"
|
||||||
|
|
||||||
|
const vuetify = createVuetify({ components, directives })
|
||||||
|
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {} unobserve() {} disconnect() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
global.visualViewport = {
|
||||||
|
width: 1024,
|
||||||
|
height: 768,
|
||||||
|
offsetLeft: 0,
|
||||||
|
offsetTop: 0,
|
||||||
|
pageLeft: 0,
|
||||||
|
pageTop: 0,
|
||||||
|
scale: 1,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
} as unknown as VisualViewport;
|
||||||
|
|
||||||
|
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||||
|
public: { apiBase: 'http://localhost:5000/' }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('$fetch', mockFetch)
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.stubGlobal('navigateTo', mockNavigate)
|
||||||
|
|
||||||
|
describe('GalleryPage.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountOptions = {
|
||||||
|
global: { plugins: [vuetify] }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows loading state initially and then renders recipes', async () => {
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce([
|
||||||
|
{ id: 1, title: 'Bolognese', createdAt: new Date().toISOString(), ingredients: [], instructions: [] }
|
||||||
|
])
|
||||||
|
|
||||||
|
const wrapper = mount(GalleryPage, mountOptions)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Opening Collection...')
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.text()).toContain('Bolognese')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enters editing mode and formats arrays into strings', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Muffins',
|
||||||
|
ingredients: ['Flour', 'Sugar'],
|
||||||
|
instructions: ['Mix', 'Bake'],
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const wrapper = mount(GalleryPage, mountOptions)
|
||||||
|
await vi.waitFor(() => expect(wrapper.vm.recipes.length).toBe(1))
|
||||||
|
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
vm.editRecipe(vm.recipes[0])
|
||||||
|
|
||||||
|
expect(vm.isEditing).toBe(true)
|
||||||
|
expect(vm.selectedRecipe.ingredients).toBe('Flour\nSugar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shoves updated recipe back to .NET API on saveChanges', async () => {
|
||||||
|
const mockRecipe = { id: 1, title: 'Old Title', ingredients: 'Water', instructions: 'Boil', createdAt: new Date().toISOString() }
|
||||||
|
mockFetch.mockResolvedValueOnce([mockRecipe])
|
||||||
|
|
||||||
|
const wrapper = mount(GalleryPage, mountOptions)
|
||||||
|
await vi.waitFor(() => expect(wrapper.vm.recipes.length).toBe(1))
|
||||||
|
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
vm.selectedRecipe = { ...mockRecipe, title: 'New Title' }
|
||||||
|
vm.isEditing = true
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({ success: true })
|
||||||
|
|
||||||
|
await vm.saveChanges()
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('api/recipe/update/1'),
|
||||||
|
expect.objectContaining({ method: 'PUT' })
|
||||||
|
)
|
||||||
|
expect(vm.recipes[0].title).toBe('New Title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects to login if API returns 401', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce({ status: 401 })
|
||||||
|
|
||||||
|
mount(GalleryPage, mountOptions)
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
54
Seasoned.Frontend/test/IndexPage.spec.ts
Normal file
54
Seasoned.Frontend/test/IndexPage.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import IndexPage from '@/pages/index.vue'
|
||||||
|
|
||||||
|
const vuetify = createVuetify({ components, directives })
|
||||||
|
|
||||||
|
const mockIsLoggedIn = ref(false)
|
||||||
|
vi.stubGlobal('useState', vi.fn((key, init) => {
|
||||||
|
if (key === 'isLoggedIn') return mockIsLoggedIn
|
||||||
|
return ref(init ? init() : null)
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('IndexPage.vue', () => {
|
||||||
|
it('renders the brand title and subtitle', () => {
|
||||||
|
const wrapper = mount(IndexPage, {
|
||||||
|
global: {
|
||||||
|
plugins: [vuetify],
|
||||||
|
stubs: { 'nuxt-link': true }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Seasoned')
|
||||||
|
expect(wrapper.text()).toContain('A Recipe Generator')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Get Started" button when NOT logged in', () => {
|
||||||
|
const wrapper = mount(IndexPage, {
|
||||||
|
global: { plugins: [vuetify] }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Get Started')
|
||||||
|
expect(wrapper.text()).not.toContain('Talk to Chef')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides "Get Started" and shows action buttons when logged in', async () => {
|
||||||
|
|
||||||
|
const wrapper = mount(IndexPage, {
|
||||||
|
global: { plugins: [vuetify] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoggedIn = useState('isLoggedIn')
|
||||||
|
isLoggedIn.value = true
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Talk to Chef')
|
||||||
|
expect(wrapper.text()).toContain('Go to Uploader')
|
||||||
|
expect(wrapper.text()).not.toContain('Get Started')
|
||||||
|
})
|
||||||
|
})
|
||||||
93
Seasoned.Frontend/test/LoginPage.spec.ts
Normal file
93
Seasoned.Frontend/test/LoginPage.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import LoginPage from "@/pages/login.vue"
|
||||||
|
|
||||||
|
const vuetify = createVuetify({ components, directives })
|
||||||
|
|
||||||
|
// Standard Mocks
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {} unobserve() {} disconnect() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||||
|
public: { apiBase: 'http://localhost:5000/' }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('$fetch', mockFetch)
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.stubGlobal('navigateTo', mockNavigate)
|
||||||
|
|
||||||
|
// Mock Nuxt's useState
|
||||||
|
vi.stubGlobal('useState', () => ({ value: false }))
|
||||||
|
|
||||||
|
describe('LoginPage.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountOptions = {
|
||||||
|
global: { plugins: [vuetify] }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('switches between Login and Register modes', async () => {
|
||||||
|
const wrapper = mount(LoginPage, mountOptions)
|
||||||
|
|
||||||
|
// Default is Login
|
||||||
|
expect(wrapper.find('h1').text()).toBe('Sign In')
|
||||||
|
expect(wrapper.find('input[label="Confirm Password"]').exists()).toBe(false)
|
||||||
|
|
||||||
|
// Click toggle
|
||||||
|
await wrapper.find('.auth-toggle-btn').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.find('h1').text()).toBe('Join Us')
|
||||||
|
// V-expand-transition might need a tick or we check the v-if logic
|
||||||
|
expect(wrapper.vm.isLogin).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error if passwords do not match in registration mode', async () => {
|
||||||
|
const wrapper = mount(LoginPage, mountOptions)
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
|
||||||
|
vm.isLogin = false
|
||||||
|
vm.email = 'test@test.com'
|
||||||
|
vm.password = 'password123'
|
||||||
|
vm.confirmPassword = 'differentPassword'
|
||||||
|
|
||||||
|
await vm.handleAuth()
|
||||||
|
|
||||||
|
expect(vm.errorMessage).toBe('Passwords do not match.')
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls login API and redirects on success', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ token: 'fake-token' })
|
||||||
|
const wrapper = mount(LoginPage, mountOptions)
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
|
||||||
|
vm.email = 'chef@seasoned.com'
|
||||||
|
vm.password = 'secret'
|
||||||
|
|
||||||
|
await vm.handleAuth()
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('api/auth/login'),
|
||||||
|
expect.any(Object)
|
||||||
|
)
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays specific error for 401 Unauthorized', async () => {
|
||||||
|
mockFetch.mockRejectedValueOnce({ status: 401 })
|
||||||
|
const wrapper = mount(LoginPage, mountOptions)
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
|
||||||
|
await vm.handleAuth()
|
||||||
|
|
||||||
|
expect(vm.errorMessage).toContain('Invalid email or password')
|
||||||
|
})
|
||||||
|
})
|
||||||
90
Seasoned.Frontend/test/RecipeDisplay.spec.ts
Normal file
90
Seasoned.Frontend/test/RecipeDisplay.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import RecipeDisplay from '../app/components/RecipeDisplay.vue'
|
||||||
|
|
||||||
|
const vuetify = createVuetify({
|
||||||
|
components,
|
||||||
|
directives,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RecipeDisplay.vue', () => {
|
||||||
|
const mockRecipe = {
|
||||||
|
title: 'Bakery-Style Muffins',
|
||||||
|
description: 'Fresh from the oven.',
|
||||||
|
ingredients: ['2 cups flour', '1 cup sugar'],
|
||||||
|
instructions: ['Preheat oven', 'Bake muffins'],
|
||||||
|
imageUrl: 'data:image/png;base64,header_captured_image'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the title and all ingredients correctly', () => {
|
||||||
|
const wrapper = mount(RecipeDisplay, {
|
||||||
|
props: { recipe: mockRecipe },
|
||||||
|
global: { plugins: [vuetify] }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.recipe-title').text()).toBe('Bakery-Style Muffins')
|
||||||
|
|
||||||
|
const ingredientItems = wrapper.findAll('.ingredient-item')
|
||||||
|
expect(ingredientItems).toHaveLength(2)
|
||||||
|
expect(ingredientItems[0].text()).toContain('2 cups flour')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays the recipe image when imageUrl is provided', () => {
|
||||||
|
const wrapper = mount(RecipeDisplay, {
|
||||||
|
props: { recipe: mockRecipe },
|
||||||
|
global: { plugins: [vuetify] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const img = wrapper.findComponent({ name: 'VImg' })
|
||||||
|
expect(img.exists()).toBe(true)
|
||||||
|
expect(img.props('src')).toBe(mockRecipe.imageUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits "save" when the save button is clicked', async () => {
|
||||||
|
const wrapper = mount(RecipeDisplay, {
|
||||||
|
props: {
|
||||||
|
recipe: mockRecipe,
|
||||||
|
isSaving: false,
|
||||||
|
hasSaved: false
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [vuetify]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveBtn = wrapper.find('.save-recipe-btn')
|
||||||
|
await saveBtn.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted()).toHaveProperty('save')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the "Saved in Archives" state when hasSaved is true', async () => {
|
||||||
|
const recipe = { title: 'Bakery-Style Muffins', ingredients: [], instructions: [] }
|
||||||
|
const wrapper = mount(RecipeDisplay, {
|
||||||
|
props: {
|
||||||
|
recipe,
|
||||||
|
hasSaved: true
|
||||||
|
},
|
||||||
|
global: { plugins: [vuetify] }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Saved in Archives')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggers the browser print dialog when the print button is clicked', async () => {
|
||||||
|
const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const wrapper = mount(RecipeDisplay, {
|
||||||
|
props: { recipe: mockRecipe },
|
||||||
|
global: { plugins: [vuetify] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const printBtn = wrapper.find('.print-btn')
|
||||||
|
await printBtn.trigger('click')
|
||||||
|
|
||||||
|
expect(printSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
Seasoned.Frontend/test/Uploader.spec.ts
Normal file
74
Seasoned.Frontend/test/Uploader.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import Uploader from "@/pages/uploader.vue"
|
||||||
|
|
||||||
|
const vuetify = createVuetify({ components, directives })
|
||||||
|
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {} unobserve() {} disconnect() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||||
|
public: { apiBase: 'http://localhost:5000/' }
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockRouter = { push: vi.fn() }
|
||||||
|
vi.stubGlobal('useRouter', () => mockRouter)
|
||||||
|
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
vi.stubGlobal('$fetch', mockFetch)
|
||||||
|
|
||||||
|
describe('Uploader.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountOptions = {
|
||||||
|
global: {
|
||||||
|
plugins: [vuetify],
|
||||||
|
stubs: { RecipeDisplay: true },
|
||||||
|
provide: { 'router': mockRouter }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the drop zone and upload button', () => {
|
||||||
|
const wrapper = mount(Uploader, mountOptions)
|
||||||
|
expect(wrapper.text()).toContain('Analyze Recipe')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the filename when a file is selected', async () => {
|
||||||
|
const wrapper = mount(Uploader, mountOptions)
|
||||||
|
const file = new File(['(data)'], 'grandmas-cookies.png', { type: 'image/png' })
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
|
||||||
|
vm.files = [file]
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.text()).toContain('grandmas-cookies.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading state on the button when analyzing', async () => {
|
||||||
|
const wrapper = mount(Uploader, mountOptions)
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
|
||||||
|
vm.loading = true
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const btn = wrapper.find('.analyze-btn')
|
||||||
|
expect(btn.attributes('class')).toContain('v-btn--loading')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores a recipe from localStorage on mount', async () => {
|
||||||
|
const savedRecipe = { title: 'Restored Cake', ingredients: [], instructions: [] }
|
||||||
|
localStorage.setItem('pending_recipe', JSON.stringify(savedRecipe))
|
||||||
|
|
||||||
|
const wrapper = mount(Uploader, mountOptions)
|
||||||
|
const vm = wrapper.vm as any
|
||||||
|
|
||||||
|
expect(vm.recipe.title).toBe('Restored Cake')
|
||||||
|
expect(localStorage.getItem('pending_recipe')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
// vitest.config.ts
|
// vitest.config.ts
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
css: false,
|
||||||
server: {
|
server: {
|
||||||
deps: {
|
deps: {
|
||||||
inline: [/@exodus\/bytes/, /html-encoding-sniffer/],
|
inline: [
|
||||||
|
/@exodus\/bytes/,
|
||||||
|
/html-encoding-sniffer/,
|
||||||
|
/vuetify/
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './app'),
|
||||||
|
'~': path.resolve(__dirname, './app')
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
124
Seasoned.Tests/RecipeControllerTests.cs
Normal file
124
Seasoned.Tests/RecipeControllerTests.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Seasoned.Backend.Controllers;
|
||||||
|
using Seasoned.Backend.Services;
|
||||||
|
using Seasoned.Backend.DTOs;
|
||||||
|
using Seasoned.Backend.Data;
|
||||||
|
using Seasoned.Backend.Models;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Seasoned.Tests;
|
||||||
|
|
||||||
|
public class RecipeControllerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IRecipeService> _mockService;
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly RecipeController _controller;
|
||||||
|
private readonly string _testUserId = "chef-123";
|
||||||
|
|
||||||
|
public RecipeControllerTests()
|
||||||
|
{
|
||||||
|
_mockService = new Mock<IRecipeService>();
|
||||||
|
|
||||||
|
// Setup InMemory Postgres replacement
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
_context = new ApplicationDbContext(options);
|
||||||
|
|
||||||
|
_controller = new RecipeController(_mockService.Object, _context);
|
||||||
|
|
||||||
|
// Mock the User Identity (User.FindFirstValue(ClaimTypes.NameIdentifier))
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, _testUserId),
|
||||||
|
}, "mock"));
|
||||||
|
|
||||||
|
_controller.ControllerContext = new ControllerContext()
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext() { User = user }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadRecipe_ReturnsOk_WithParsedDataAndImage()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var fakeRecipe = new RecipeResponseDto
|
||||||
|
{
|
||||||
|
Title = "Roasted Garlic Pasta",
|
||||||
|
ImageUrl = "data:image/png;base64,header_captured_image"
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockService.Setup(s => s.ParseRecipeImageAsync(It.IsAny<IFormFile>()))
|
||||||
|
.ReturnsAsync(fakeRecipe);
|
||||||
|
|
||||||
|
var file = CreateMockFile("test-recipe.jpg");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _controller.UploadRecipe(file);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var okResult = result.Result as OkObjectResult;
|
||||||
|
okResult.Should().NotBeNull();
|
||||||
|
var returned = okResult!.Value as RecipeResponseDto;
|
||||||
|
returned!.Title.Should().Be("Roasted Garlic Pasta");
|
||||||
|
returned.ImageUrl.Should().Contain("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveRecipe_PersistsToPostgres_ForCurrentUser()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dto = new RecipeResponseDto
|
||||||
|
{
|
||||||
|
Title = "Jenkins Special Brew",
|
||||||
|
Ingredients = new List<string> { "Coffee", "Code" },
|
||||||
|
ImageUrl = "base64-string"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _controller.SaveRecipe(dto);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var okResult = result as OkResult; // Or OkObjectResult based on your Controller return
|
||||||
|
var savedRecipe = await _context.Recipes.FirstOrDefaultAsync(r => r.Title == "Jenkins Special Brew");
|
||||||
|
|
||||||
|
savedRecipe.Should().NotBeNull();
|
||||||
|
savedRecipe!.UserId.Should().Be(_testUserId);
|
||||||
|
savedRecipe.ImageUrl.Should().Be("base64-string");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConsultChef_ReturnsChefResponse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var request = new ChatRequestDto { Prompt = "How do I sear a steak?" };
|
||||||
|
var expectedResponse = new ChefConsultResponseDto { Reply = "High heat, my friend!" };
|
||||||
|
|
||||||
|
_mockService.Setup(s => s.ConsultChefAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(expectedResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _controller.Consult(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var okResult = result as OkObjectResult;
|
||||||
|
var response = okResult!.Value as ChefConsultResponseDto;
|
||||||
|
response!.Reply.Should().Be("High heat, my friend!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private IFormFile CreateMockFile(string fileName)
|
||||||
|
{
|
||||||
|
var ms = new MemoryStream();
|
||||||
|
var writer = new StreamWriter(ms);
|
||||||
|
writer.Write("fake image binary data");
|
||||||
|
writer.Flush();
|
||||||
|
ms.Position = 0;
|
||||||
|
return new FormFile(ms, 0, ms.Length, "image", fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Seasoned.Tests/RecipeServiceTests.cs
Normal file
70
Seasoned.Tests/RecipeServiceTests.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Seasoned.Backend.Services;
|
||||||
|
using Seasoned.Backend.DTOs;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Seasoned.Tests;
|
||||||
|
|
||||||
|
public class RecipeServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IConfiguration> _mockConfig;
|
||||||
|
private readonly RecipeService _service;
|
||||||
|
|
||||||
|
public RecipeServiceTests()
|
||||||
|
{
|
||||||
|
_mockConfig = new Mock<IConfiguration>();
|
||||||
|
// Mock the API Key retrieval
|
||||||
|
_mockConfig.Setup(c => c["GEMINI_API_KEY"]).Returns("fake-api-key-123");
|
||||||
|
|
||||||
|
_service = new RecipeService(_mockConfig.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ThrowsException_WhenApiKeyIsMissing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var emptyConfig = new Mock<IConfiguration>();
|
||||||
|
emptyConfig.Setup(c => c["GEMINI_API_KEY"]).Returns((string?)null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
Action act = () => new RecipeService(emptyConfig.Object);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
act.Should().Throw<ArgumentNullException>().WithMessage("*API Key missing*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParseRecipeImageAsync_ReturnsError_WhenImageIsEmpty()
|
||||||
|
{
|
||||||
|
// Arrange: Create an empty file
|
||||||
|
var fileMock = new Mock<IFormFile>();
|
||||||
|
fileMock.Setup(f => f.Length).Returns(0);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var result = await _service.ParseRecipeImageAsync(fileMock.Object);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseRecipeImageAsync_AttachesCorrectBase64Header()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var content = "fake-image-data";
|
||||||
|
var fileName = "test.png";
|
||||||
|
var ms = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||||
|
var mockFile = new FormFile(ms, 0, ms.Length, "image", fileName)
|
||||||
|
{
|
||||||
|
Headers = new HeaderDictionary(),
|
||||||
|
ContentType = "image/png"
|
||||||
|
};
|
||||||
|
|
||||||
|
var resultImageUrl = $"data:{mockFile.ContentType};base64,{Convert.ToBase64String(ms.ToArray())}";
|
||||||
|
|
||||||
|
resultImageUrl.Should().StartWith("data:image/png;base64,");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,20 +8,24 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||||
|
<_Parameter1>Seasoned.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Seasoned.Backend\Seasoned.Backend.csproj" />
|
<ProjectReference Include="..\Seasoned.Backend\Seasoned.Backend.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
using Moq;
|
|
||||||
using Xunit;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Seasoned.Backend.Controllers;
|
|
||||||
using Seasoned.Backend.Services;
|
|
||||||
using Seasoned.Backend.DTOs;
|
|
||||||
|
|
||||||
namespace Seasoned.Tests;
|
|
||||||
|
|
||||||
public class RecipeControllerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task ParseRecipe_ReturnsOk_WhenImageIsValid()
|
|
||||||
{
|
|
||||||
// 1. Arrange: Create a "Fake" Service
|
|
||||||
var mockService = new Mock<IRecipeService>();
|
|
||||||
var fakeRecipe = new RecipeResponseDto { Title = "Test Recipe" };
|
|
||||||
|
|
||||||
mockService.Setup(s => s.ParseImageAsync(It.IsAny<IFormFile>()))
|
|
||||||
.ReturnsAsync(fakeRecipe);
|
|
||||||
|
|
||||||
var controller = new RecipeController(mockService.Object);
|
|
||||||
|
|
||||||
// Create a fake image file
|
|
||||||
var content = "fake image content";
|
|
||||||
var fileName = "test.jpg";
|
|
||||||
var ms = new MemoryStream();
|
|
||||||
var writer = new StreamWriter(ms);
|
|
||||||
writer.Write(content);
|
|
||||||
writer.Flush();
|
|
||||||
ms.Position = 0;
|
|
||||||
var mockFile = new FormFile(ms, 0, ms.Length, "id_from_form", fileName);
|
|
||||||
|
|
||||||
// 2. Act: Call the Controller
|
|
||||||
var result = await controller.ParseRecipe(mockFile);
|
|
||||||
|
|
||||||
// 3. Assert: Check the result
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
okResult.Should().NotBeNull();
|
|
||||||
okResult!.StatusCode.Should().Be(200);
|
|
||||||
|
|
||||||
var returnedRecipe = okResult.Value as RecipeResponseDto;
|
|
||||||
returnedRecipe!.Title.Should().Be("Test Recipe");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user