/**
* Functional tests for Mealie MCP Server
* These tests run against a live Mealie API instance
*
* Prerequisites:
* - MEALIE_BASE_URL and MEALIE_API_KEY must be set in .env
* - A running Mealie instance accessible at the configured URL
*
* Run with: npm test
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { config } from "dotenv";
import { MealieClient } from "../client.js";
import type { Recipe, RecipeNote } from "../types/index.js";
// Load environment variables
config();
const BASE_URL = process.env.MEALIE_BASE_URL;
const API_KEY = process.env.MEALIE_API_KEY;
// Skip all tests if credentials are not configured
const describeIfConfigured = BASE_URL && API_KEY ? describe : describe.skip;
describeIfConfigured("Functional Tests - Live API", () => {
let client: MealieClient;
// Track created resources for cleanup
const createdRecipeSlugs: string[] = [];
const createdFoodIds: string[] = [];
beforeAll(async () => {
if (!BASE_URL || !API_KEY) {
throw new Error("MEALIE_BASE_URL and MEALIE_API_KEY must be set");
}
client = new MealieClient(BASE_URL, API_KEY);
});
afterAll(async () => {
// Cleanup: Delete all created recipes
for (const slug of createdRecipeSlugs) {
try {
// Use the client's internal method via a direct fetch
const response = await fetch(`${BASE_URL}/api/recipes/${slug}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${API_KEY}`,
},
});
if (response.ok) {
console.log(`Cleaned up recipe: ${slug}`);
}
} catch {
// Ignore cleanup errors
}
}
// Cleanup: Delete created foods (only test-specific ones)
for (const foodId of createdFoodIds) {
try {
await client.deleteFood(foodId);
console.log(`Cleaned up food: ${foodId}`);
} catch {
// Ignore cleanup errors
}
}
});
describe("Connection", () => {
it("should connect to Mealie API successfully", async () => {
await expect(client.testConnection()).resolves.not.toThrow();
});
});
describe("Recipe CRUD Operations", () => {
const testRecipeName = `Test Recipe ${Date.now()}`;
let testRecipeSlug: string;
afterEach(async () => {
// Track for cleanup
if (testRecipeSlug && !createdRecipeSlugs.includes(testRecipeSlug)) {
createdRecipeSlugs.push(testRecipeSlug);
}
});
it("should create a basic recipe", async () => {
const slug = await client.createRecipe(testRecipeName);
testRecipeSlug = slug;
expect(slug).toBeTruthy();
expect(typeof slug).toBe("string");
// Verify the recipe exists
const recipe = await client.getRecipe(slug);
expect(recipe.name).toBe(testRecipeName);
});
it("should get recipe details", async () => {
const recipe = await client.getRecipe(testRecipeSlug);
expect(recipe).toBeDefined();
expect(recipe.id).toBeTruthy();
expect(recipe.slug).toBe(testRecipeSlug);
expect(recipe.name).toBe(testRecipeName);
});
it("should update recipe with ingredients and instructions", async () => {
const recipe = await client.getRecipe(testRecipeSlug);
const updatedRecipe = await client.updateRecipe(testRecipeSlug, {
...recipe,
recipeIngredient: [
{ note: "2 cups flour", disableAmount: true },
{ note: "1 tsp salt", disableAmount: true },
],
recipeInstructions: [
{ text: "Mix ingredients", ingredientReferences: [] },
{ text: "Bake at 350F", ingredientReferences: [] },
],
});
expect(updatedRecipe.recipeIngredient).toHaveLength(2);
expect(updatedRecipe.recipeInstructions).toHaveLength(2);
});
it("should list recipes", async () => {
const result = await client.getRecipes({ perPage: 10 });
expect(result).toBeDefined();
expect(result.items).toBeInstanceOf(Array);
expect(result.page).toBeGreaterThanOrEqual(1);
// perPage might not be returned by the API
expect(result.items.length).toBeLessThanOrEqual(10);
});
it("should search recipes by name", async () => {
const result = await client.getRecipes({ search: testRecipeName.slice(0, 10) });
expect(result.items.length).toBeGreaterThanOrEqual(1);
const found = result.items.find(r => r.slug === testRecipeSlug);
expect(found).toBeDefined();
});
});
describe("Recipe Delete", () => {
it("should delete a recipe", async () => {
// Create a recipe to delete
const name = `Delete Test Recipe ${Date.now()}`;
const slug = await client.createRecipe(name);
// Verify it exists
const recipe = await client.getRecipe(slug);
expect(recipe.name).toBe(name);
// Delete it
await client.deleteRecipe(slug);
// Verify it no longer exists
await expect(client.getRecipe(slug)).rejects.toThrow();
});
it("should throw error when deleting non-existent recipe", async () => {
const fakeSlug = `non-existent-recipe-${Date.now()}`;
await expect(client.deleteRecipe(fakeSlug)).rejects.toThrow();
});
});
describe("Recipe Notes", () => {
let recipeSlug: string;
beforeAll(async () => {
const name = `Notes Test Recipe ${Date.now()}`;
recipeSlug = await client.createRecipe(name);
createdRecipeSlugs.push(recipeSlug);
});
it("should add notes to a recipe", async () => {
const recipe = await client.getRecipe(recipeSlug);
const notes: RecipeNote[] = [
{ title: "Chef's Tip", text: "Use room temperature butter for best results" },
{ title: "Storage", text: "Store in airtight container for up to 3 days" },
];
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
notes,
});
expect(updatedRecipe.notes).toHaveLength(2);
expect(updatedRecipe.notes[0].title).toBe("Chef's Tip");
expect(updatedRecipe.notes[0].text).toBe("Use room temperature butter for best results");
expect(updatedRecipe.notes[1].title).toBe("Storage");
});
it("should update existing notes", async () => {
const recipe = await client.getRecipe(recipeSlug);
const updatedNotes: RecipeNote[] = [
{ title: "Updated Tip", text: "This tip has been updated" },
];
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
notes: updatedNotes,
});
expect(updatedRecipe.notes).toHaveLength(1);
expect(updatedRecipe.notes[0].title).toBe("Updated Tip");
});
});
describe("Recipe Description", () => {
let recipeSlug: string;
beforeAll(async () => {
const name = `Description Test Recipe ${Date.now()}`;
recipeSlug = await client.createRecipe(name);
createdRecipeSlugs.push(recipeSlug);
});
it("should set description on a recipe", async () => {
const recipe = await client.getRecipe(recipeSlug);
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
description: "A delicious homemade recipe perfect for family dinners.",
});
expect(updatedRecipe.description).toBe("A delicious homemade recipe perfect for family dinners.");
});
it("should update an existing description", async () => {
const recipe = await client.getRecipe(recipeSlug);
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
description: "Updated description with new details about the recipe.",
});
expect(updatedRecipe.description).toBe("Updated description with new details about the recipe.");
});
it("should clear description by setting empty string", async () => {
const recipe = await client.getRecipe(recipeSlug);
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
description: "",
});
expect(updatedRecipe.description).toBe("");
});
});
describe("Recipe Servings and Yield", () => {
let recipeSlug: string;
beforeAll(async () => {
const name = `Servings Test Recipe ${Date.now()}`;
recipeSlug = await client.createRecipe(name);
createdRecipeSlugs.push(recipeSlug);
});
it("should set servings on a recipe", async () => {
const recipe = await client.getRecipe(recipeSlug);
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
recipeServings: 6,
});
expect(updatedRecipe.recipeServings).toBe(6);
});
it("should set yield text on a recipe", async () => {
const recipe = await client.getRecipe(recipeSlug);
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
recipeYield: "12 cookies",
});
expect(updatedRecipe.recipeYield).toBe("12 cookies");
});
it("should set both servings and yield together", async () => {
const recipe = await client.getRecipe(recipeSlug);
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
recipeServings: 4,
recipeYield: "1 loaf",
});
expect(updatedRecipe.recipeServings).toBe(4);
expect(updatedRecipe.recipeYield).toBe("1 loaf");
});
});
describe("Foods API", () => {
const testFoodName = `TestFood${Date.now()}`;
let testFoodId: string;
it("should list foods", async () => {
const result = await client.getFoods({ perPage: 10 });
expect(result).toBeDefined();
expect(result.items).toBeInstanceOf(Array);
});
it("should create a new food", async () => {
const food = await client.createFood({ name: testFoodName });
testFoodId = food.id;
createdFoodIds.push(testFoodId);
expect(food.id).toBeTruthy();
expect(food.name).toBe(testFoodName);
});
it("should get food by ID", async () => {
const food = await client.getFood(testFoodId);
expect(food.id).toBe(testFoodId);
expect(food.name).toBe(testFoodName);
});
it("should search for foods", async () => {
const result = await client.getFoods({ search: testFoodName });
expect(result.items.length).toBeGreaterThanOrEqual(1);
const found = result.items.find(f => f.id === testFoodId);
expect(found).toBeDefined();
});
it("should find existing food with findOrCreateFood", async () => {
const food = await client.findOrCreateFood(testFoodName);
expect(food.id).toBe(testFoodId);
expect(food.name).toBe(testFoodName);
});
it("should create new food with findOrCreateFood if not exists", async () => {
const uniqueName = `AutoCreatedFood${Date.now()}`;
const food = await client.findOrCreateFood(uniqueName);
createdFoodIds.push(food.id);
expect(food.id).toBeTruthy();
expect(food.name).toBe(uniqueName);
});
});
describe("Units API", () => {
it("should list units", async () => {
const result = await client.getUnits({ perPage: 50 });
expect(result).toBeDefined();
expect(result.items).toBeInstanceOf(Array);
});
it("should find existing unit with findOrCreateUnit", async () => {
// Most Mealie installations have common units like "cup"
const result = await client.getUnits({ perPage: 50 });
if (result.items.length > 0) {
const existingUnit = result.items[0];
const found = await client.findOrCreateUnit(existingUnit.name);
expect(found.id).toBe(existingUnit.id);
}
});
it("should create new unit with findOrCreateUnit if not exists", async () => {
const uniqueName = `testunit${Date.now()}`;
const unit = await client.findOrCreateUnit(uniqueName);
expect(unit.id).toBeTruthy();
expect(unit.name).toBe(uniqueName);
// Units can't be deleted via API typically, so we just leave it
});
});
describe("Structured Ingredients with Auto-Create", () => {
let recipeSlug: string;
const uniqueFoodName = `StructuredTestFood${Date.now()}`;
beforeAll(async () => {
const name = `Structured Ingredients Recipe ${Date.now()}`;
recipeSlug = await client.createRecipe(name);
createdRecipeSlugs.push(recipeSlug);
});
it("should create recipe with structured ingredients that auto-create foods", async () => {
const recipe = await client.getRecipe(recipeSlug);
// First, create/find the food
const food = await client.findOrCreateFood(uniqueFoodName);
createdFoodIds.push(food.id);
// Then, find/create a unit
const unit = await client.findOrCreateUnit("cups");
// Update recipe with structured ingredient
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
recipeIngredient: [
{
quantity: 2,
unit: { id: unit.id, name: unit.name },
food: { id: food.id, name: food.name },
note: "sifted",
disableAmount: false,
isFood: true,
},
],
});
expect(updatedRecipe.recipeIngredient).toHaveLength(1);
expect(updatedRecipe.recipeIngredient[0].quantity).toBe(2);
expect(updatedRecipe.recipeIngredient[0].food?.name).toBe(uniqueFoodName);
expect(updatedRecipe.recipeIngredient[0].unit?.name).toBe("cups");
expect(updatedRecipe.recipeIngredient[0].note).toBe("sifted");
});
it("should support mixed plain text and structured ingredients", async () => {
const recipe = await client.getRecipe(recipeSlug);
const food = await client.findOrCreateFood("butter");
const unit = await client.findOrCreateUnit("tablespoons");
const updatedRecipe = await client.updateRecipe(recipeSlug, {
...recipe,
recipeIngredient: [
// Plain text ingredient
{ note: "2 cups all-purpose flour", disableAmount: true },
// Structured ingredient
{
quantity: 4,
unit: { id: unit.id, name: unit.name },
food: { id: food.id, name: food.name },
note: "softened",
disableAmount: false,
isFood: true,
},
// Another plain text
{ note: "1 pinch of salt", disableAmount: true },
],
});
expect(updatedRecipe.recipeIngredient).toHaveLength(3);
// Check plain text (disableAmount may not be returned by API)
expect(updatedRecipe.recipeIngredient[0].note).toBe("2 cups all-purpose flour");
// Check structured
expect(updatedRecipe.recipeIngredient[1].quantity).toBe(4);
expect(updatedRecipe.recipeIngredient[1].food?.name).toBe("butter");
// Check another plain text
expect(updatedRecipe.recipeIngredient[2].note).toBe("1 pinch of salt");
});
});
describe("Image Operations", () => {
let recipeSlug: string;
beforeAll(async () => {
const name = `Image Test Recipe ${Date.now()}`;
recipeSlug = await client.createRecipe(name);
createdRecipeSlugs.push(recipeSlug);
});
it("should fetch image from URL", async () => {
// Use a small, reliable test image from a fast CDN
const testImageUrl = "https://picsum.photos/100/100";
const result = await client.fetchImageFromUrl(testImageUrl);
expect(result.data).toBeInstanceOf(Buffer);
expect(result.data.length).toBeGreaterThan(0);
expect(result.contentType).toMatch(/image/);
}, 15000); // 15 second timeout for network request
it("should upload image to recipe from URL", async () => {
const testImageUrl = "https://picsum.photos/150/150";
// Fetch the image
const { data, contentType } = await client.fetchImageFromUrl(testImageUrl);
// Determine extension from content type
const ext = contentType.split("/").pop() || "jpeg";
// Upload to recipe
await expect(
client.uploadRecipeImage(recipeSlug, data, `test-image.${ext}`)
).resolves.not.toThrow();
// Verify recipe has image (check updated recipe)
const recipe = await client.getRecipe(recipeSlug);
// Recipe should have image field set after upload
expect(recipe).toBeDefined();
}, 20000); // 20 second timeout for fetch + upload
it("should upload image from base64 data", async () => {
// Create a minimal valid PNG (1x1 pixel, red)
const minimalPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
const imageData = Buffer.from(minimalPngBase64, "base64");
await expect(
client.uploadRecipeImage(recipeSlug, imageData, "test-base64.png")
).resolves.not.toThrow();
});
it("should handle invalid image URL gracefully", async () => {
const invalidUrl = "https://invalid.example.com/nonexistent.jpg";
await expect(client.fetchImageFromUrl(invalidUrl)).rejects.toThrow();
});
});
describe("Mealplan Operations", () => {
it("should get today's mealplan", async () => {
const mealplans = await client.getTodaysMealplan();
expect(mealplans).toBeInstanceOf(Array);
});
it("should list mealplans with date range", async () => {
const today = new Date();
const startDate = today.toISOString().split("T")[0];
const endDate = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0];
const result = await client.getMealplans({
startDate,
endDate,
});
expect(result).toBeDefined();
expect(result.items).toBeInstanceOf(Array);
});
it("should create a mealplan entry with title", async () => {
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
const dateStr = tomorrow.toISOString().split("T")[0];
const mealplan = await client.createMealplan({
date: dateStr,
title: `Test Meal ${Date.now()}`,
entryType: "dinner",
});
expect(mealplan).toBeDefined();
expect(mealplan.date).toBe(dateStr);
});
});
describe("Complete Recipe Creation Flow", () => {
it("should create a complete recipe with all features", async () => {
const recipeName = `Complete Recipe ${Date.now()}`;
// Step 1: Create the recipe
const slug = await client.createRecipe(recipeName);
createdRecipeSlugs.push(slug);
// Step 2: Get the recipe to have all fields
const recipe = await client.getRecipe(slug);
// Step 3: Find or create foods and units
const flour = await client.findOrCreateFood("all-purpose flour");
const sugar = await client.findOrCreateFood("granulated sugar");
const cups = await client.findOrCreateUnit("cups");
const tsp = await client.findOrCreateUnit("teaspoons");
// Step 4: Update with all features
const notes: RecipeNote[] = [
{ title: "Prep Tip", text: "Sift the flour for a lighter texture" },
{ title: "Variation", text: "Add chocolate chips for extra flavor" },
];
const updatedRecipe = await client.updateRecipe(slug, {
...recipe,
recipeServings: 8,
recipeYield: "24 cookies",
notes,
recipeIngredient: [
{
quantity: 2.5,
unit: { id: cups.id, name: cups.name },
food: { id: flour.id, name: flour.name },
note: "sifted",
disableAmount: false,
isFood: true,
},
{
quantity: 1,
unit: { id: cups.id, name: cups.name },
food: { id: sugar.id, name: sugar.name },
disableAmount: false,
isFood: true,
},
{ note: "2 large eggs", disableAmount: true },
{
quantity: 1,
unit: { id: tsp.id, name: tsp.name },
food: { id: (await client.findOrCreateFood("vanilla extract")).id, name: "vanilla extract" },
disableAmount: false,
isFood: true,
},
],
recipeInstructions: [
{ text: "Preheat oven to 350°F (175°C)", ingredientReferences: [] },
{ text: "Cream butter and sugar until fluffy", ingredientReferences: [] },
{ text: "Add eggs one at a time, mixing well", ingredientReferences: [] },
{ text: "Gradually add flour mixture", ingredientReferences: [] },
{ text: "Drop spoonfuls onto baking sheet", ingredientReferences: [] },
{ text: "Bake for 10-12 minutes until golden", ingredientReferences: [] },
],
});
// Verify all features
expect(updatedRecipe.name).toBe(recipeName);
expect(updatedRecipe.recipeServings).toBe(8);
expect(updatedRecipe.recipeYield).toBe("24 cookies");
expect(updatedRecipe.notes).toHaveLength(2);
expect(updatedRecipe.recipeIngredient).toHaveLength(4);
expect(updatedRecipe.recipeInstructions).toHaveLength(6);
// Verify structured ingredients
const flourIngredient = updatedRecipe.recipeIngredient[0];
expect(flourIngredient.quantity).toBe(2.5);
expect(flourIngredient.food?.name).toBe("all-purpose flour");
expect(flourIngredient.unit?.name).toBe("cups");
expect(flourIngredient.note).toBe("sifted");
// Verify plain text ingredient (disableAmount may not be returned by API)
const eggsIngredient = updatedRecipe.recipeIngredient[2];
expect(eggsIngredient.note).toBe("2 large eggs");
console.log(`Successfully created complete recipe: ${slug}`);
});
});
});