Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions api/src/models/Recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { Ingredient, ReservedIngredientTags } from './Ingredient.js';
import { ownerExists, tagsExist, unique, uniqueInAdminsAndUser } from './validation.js';

const quantityRegex = /^((\d+(\.\d+)?|[1-9]\d*\/[1-9]\d*)(-(\d+(\.\d+)?|[1-9]\d*\/[1-9]\d*))?)$/;
const ReservedRecipeTags = { Ingredient: 'ingredient' } as const;
const ReservedRecipeTags = {
Ingredient: 'ingredient',
PrepAhead: 'prep_ahead',
} as const;
export const ReservedTags = { ...ReservedRecipeTags, ...ReservedIngredientTags } as const;
type ReservedTags = (typeof ReservedTags)[keyof typeof ReservedTags];

Expand Down Expand Up @@ -162,6 +165,8 @@ export interface Recipe extends Document {
source?: string;
numServings: number;
isIngredient: boolean;
prepAhead: boolean;
prepAheadLabel?: string;
createdAt: Date;
lastModified: Date;
}
Expand Down Expand Up @@ -250,6 +255,8 @@ const recipeSchema = new Schema<Recipe>({
source: { type: String },
numServings: { type: Number, required: true },
isIngredient: { type: Boolean, required: true },
prepAhead: { type: Boolean, required: true, default: false },
prepAheadLabel: { type: String },
createdAt: { type: Date, required: true },
lastModified: { type: Date, required: true },
});
Expand All @@ -262,6 +269,9 @@ recipeSchema.pre('save', async function () {
if (this.isIngredient) {
calculatedTags.push(ReservedRecipeTags.Ingredient);
}
if (this.prepAhead) {
calculatedTags.push(ReservedRecipeTags.PrepAhead);
}
for (const tag in ReservedIngredientTags) {
const allMembers = this.ingredientSubsections.every((collection: IngredientSubsection) => {
return collection.ingredients.every((recipeIngr: RecipeIngredientType) => {
Expand All @@ -280,7 +290,21 @@ recipeSchema.pre('save', async function () {
calculatedTags.push(ReservedIngredientTags[tag]);
}
}
this.calculatedTags = calculatedTags;
// Add prep_ahead if any ingredient recipe is prepAhead
const hasPrepAheadIngredient = this.ingredientSubsections.some(
(subsection: IngredientSubsection) =>
subsection.ingredients.some((recipeIngr: RecipeIngredientType) => {
if (recipeIngr.type === 'recipe') {
const recipe: Recipe = recipeIngr.ingredient as unknown as Recipe;
return recipe.prepAhead;
}
return false;
})
);
if (hasPrepAheadIngredient) {
calculatedTags.push(ReservedRecipeTags.PrepAhead);
}
this.calculatedTags = [...new Set(calculatedTags)];
});

export const RecipeIngredient = model<RecipeIngredientType>(
Expand Down
17 changes: 17 additions & 0 deletions api/src/scripts/updateSchema_2026-03-29.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* eslint-env mongo */
const collectionName = process.env.COLLECTION;
if (!collectionName) {
print('Error: Please provide a collection name as an argument.');
quit(1);
}
const db = db.getSiblingDB(collectionName);

db.recipes.find({ prepAhead: { $exists: false } }).forEach((doc) => {
db.recipes.updateOne({ _id: doc._id }, { $set: { prepAhead: false } });
print(`Updated document with _id: ${doc._id}`);
});

print('prepAhead field update complete.');

// Run with:
// COLLECTION=dbName mongosh "mongodb://localhost:27017" updateSchema_2026-03-29.js
131 changes: 131 additions & 0 deletions api/test/graphql/Recipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const getDefaultRecipeRecord = (ingredient, unit, prepMethod, tag?, size?) => {
numServings: 4,
tags: [],
isIngredient: false,
prepAhead: false,
};
if (tag) {
record.tags = [tag._id];
Expand Down Expand Up @@ -174,6 +175,7 @@ describe('recipeCreateOne', () => {
numServings: 4,
tags: [tag._id],
isIngredient: false,
prepAhead: false,
};
const response = await createRecipe(this, user, newRecord);
const record = parseCreatedRecipe(response);
Expand Down Expand Up @@ -218,6 +220,7 @@ describe('recipeCreateOne', () => {
numServings: 4,
tags: [tag._id],
isIngredient: false,
prepAhead: false,
};
const response = await createRecipe(this, user, newRecord);
const record = parseCreatedRecipe(response);
Expand Down Expand Up @@ -425,6 +428,7 @@ describe('recipeCreateOne', () => {
numServings: 4,
tags: [tag._id],
isIngredient: false,
prepAhead: false,
};
await createRecipe(this, user, newRecord);
const response = await createRecipe(this, user, newRecord);
Expand Down Expand Up @@ -531,6 +535,117 @@ describe('recipeCreateOne', () => {
);
});

it('should create a recipe with prepAhead producing prep_ahead calculatedTag', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
const unit = await Unit.findOne({ shortSingular: 'g' });
const prepMethod = await PrepMethod.findOne({ value: 'chopped' });
const newRecord = {
...getDefaultRecipeRecord(ingredient, unit, prepMethod),
title: 'Prep Ahead Soup',
isIngredient: true,
prepAhead: true,
prepAheadLabel: '1 day',
};
const response = await createRecipe(this, user, newRecord);
const record = parseCreatedRecipe(response);
const doc = await Recipe.findById(record._id);
assert.isTrue(
doc.calculatedTags.includes('prep_ahead'),
'Recipe should have prep_ahead tag'
);
});

it('should create a recipe with prepAhead false NOT producing prep_ahead calculatedTag', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
const unit = await Unit.findOne({ shortSingular: 'g' });
const prepMethod = await PrepMethod.findOne({ value: 'chopped' });
const newRecord = {
...getDefaultRecipeRecord(ingredient, unit, prepMethod),
title: 'No Prep Ahead Soup',
prepAhead: false,
};
const response = await createRecipe(this, user, newRecord);
const record = parseCreatedRecipe(response);
const doc = await Recipe.findById(record._id);
assert.isFalse(
doc.calculatedTags.includes('prep_ahead'),
'Recipe should not have prep_ahead tag'
);
});

it('should propagate prep_ahead tag from a prepAhead recipe ingredient', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
const unit = await Unit.findOne({ shortSingular: 'g' });
const prepMethod = await PrepMethod.findOne({ value: 'chopped' });

// First, update the Bimibap recipe to be prepAhead
const recipeIngredient = await Recipe.findOne({ title: 'Bimibap' });
recipeIngredient.prepAhead = true;
recipeIngredient.prepAheadLabel = '1 day';
await recipeIngredient.save();

// Create a parent recipe using the prepAhead recipe as an ingredient
const newRecord = {
...getDefaultRecipeRecord(ingredient, unit, prepMethod),
title: 'Parent Prep Ahead Soup',
};
newRecord.ingredientSubsections[0].ingredients = [
{
ingredient: recipeIngredient._id,
quantity: '5',
unit: unit._id,
size: undefined,
prepMethod: undefined,
},
];
const response = await createRecipe(this, user, newRecord);
const record = parseCreatedRecipe(response);
const doc = await Recipe.findById(record._id);
assert.isTrue(
doc.calculatedTags.includes('prep_ahead'),
'Parent recipe should have prep_ahead tag from ingredient'
);
});

it('should NOT propagate prep_ahead tag from a non-prepAhead recipe ingredient', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
const unit = await Unit.findOne({ shortSingular: 'g' });
const prepMethod = await PrepMethod.findOne({ value: 'chopped' });

// Bimibap has prepAhead: false by default (or undefined, which is falsy)
const recipeIngredient = await Recipe.findOne({ title: 'Bimibap' });
assert.isNotTrue(
recipeIngredient.prepAhead,
'Precondition: Bimibap should not be prepAhead'
);

// Create a parent recipe using the non-prepAhead recipe as an ingredient
const newRecord = {
...getDefaultRecipeRecord(ingredient, unit, prepMethod),
title: 'Parent No Prep Ahead Soup',
};
newRecord.ingredientSubsections[0].ingredients = [
{
ingredient: recipeIngredient._id,
quantity: '5',
unit: unit._id,
size: undefined,
prepMethod: undefined,
},
];
const response = await createRecipe(this, user, newRecord);
const record = parseCreatedRecipe(response);
const doc = await Recipe.findById(record._id);
assert.isFalse(
doc.calculatedTags.includes('prep_ahead'),
'Parent recipe should not have prep_ahead tag'
);
});

it('should generate different suffixes for different recipes with same title', async function () {
const user = await User.findOne({ username: 'testuser1' });
const ingredient = await Ingredient.findOne({ name: 'chicken' });
Expand Down Expand Up @@ -1298,3 +1413,19 @@ describe('recipeRemoveById', () => {
assert.isNotEmpty(images, 'Images should not be deleted');
});
});

describe('reservedTags', () => {
before(startServer);
after(stopServer);
beforeEach(createRecipeIngredientData);
afterEach(removeRecipeIngredientData);

it('should NOT create a Tag with reserved value prep_ahead', async function () {
try {
await new Tag({ value: 'prep_ahead' }).save();
assert.fail('Tag creation should have failed with Reserved tag error');
} catch (error) {
assert.include(error.message, 'Reserved tag.');
}
});
});
13 changes: 0 additions & 13 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 83 additions & 0 deletions client/src/__tests__/index.prepahead.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import createFetchMock from 'vitest-fetch-mock';
import { userEvent } from '@testing-library/user-event';
import { cleanup, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { loadDevMessages, loadErrorMessages } from '@apollo/client/dev';

import { enterEditRecipePage } from '@recipe/utils/tests';
import { mockGetRecipePrepAhead } from '@recipe/graphql/queries/__mocks__/recipe';
import { mockZeroLinkedRecipeOne } from '@recipe/graphql/queries/__mocks__/recipe';
import { mockUpdateRecipeOneNoChange } from '@recipe/graphql/mutations/__mocks__/recipe';
import { mockUpdateRecipeAddPrepAhead } from '@recipe/graphql/mutations/__mocks__/recipe';
import { mockUpdateRecipeRemovePrepAhead } from '@recipe/graphql/mutations/__mocks__/recipe';

import { renderComponent } from './utils';

const fetchMocker = createFetchMock(vi);
fetchMocker.enableMocks();

loadErrorMessages();
loadDevMessages();

describe('Update Recipe Workflow: Prep Ahead', () => {
afterEach(() => {
cleanup();
});

it('should update recipe to enable prep ahead', async () => {
// Render -----------------------------------------------
renderComponent([mockUpdateRecipeAddPrepAhead, mockZeroLinkedRecipeOne]);
const user = userEvent.setup();

// Act --------------------------------------------------
await enterEditRecipePage(screen, user, 'Mock Recipe', 'Instruction one.');
await user.click(screen.getByLabelText('Toggle recipe as ingredient'));
await user.click(screen.getByLabelText('Edit recipe plural title'));
await user.keyboard('Mock Recipes');
await user.click(screen.getByLabelText('Toggle prep ahead'));
await user.click(screen.getByLabelText('Edit prep ahead label'));
await user.keyboard('1 day');
await user.click(screen.getByLabelText('Save recipe'));

// Expect ------------------------------------------------
// ------ Home Page --------------------------------------
expect(await screen.findByText('Recipes')).not.toBeNull();
expect(screen.queryByText('Mock Recipes')).not.toBeNull();
expect(screen.queryByText('prep ahead')).not.toBeNull();
});

it('should update recipe to disable prep ahead', async () => {
// Render -----------------------------------------------
renderComponent([mockUpdateRecipeRemovePrepAhead, mockGetRecipePrepAhead]);
const user = userEvent.setup();

// Act --------------------------------------------------
await enterEditRecipePage(screen, user, 'Mock Recipe Prep Ahead', 'Instruction one.');
await user.click(screen.getByLabelText('Toggle prep ahead'));
await user.click(screen.getByLabelText('Save recipe'));

// Expect ------------------------------------------------
// ------ Home Page --------------------------------------
expect(await screen.findByText('Recipes')).not.toBeNull();
expect(screen.queryByText('prep ahead')).toBeNull();
});

it('should disable prep ahead when isIngredient is toggled off', async () => {
// Render -----------------------------------------------
renderComponent([mockUpdateRecipeOneNoChange, mockZeroLinkedRecipeOne]);
const user = userEvent.setup();

// Act --------------------------------------------------
await enterEditRecipePage(screen, user, 'Mock Recipe', 'Instruction one.');
await user.click(screen.getByLabelText('Toggle recipe as ingredient'));
await user.click(screen.getByLabelText('Toggle prep ahead'));
// Toggle isIngredient off — should reset prep ahead
await user.click(screen.getByLabelText('Toggle recipe as ingredient'));
await user.click(screen.getByLabelText('Save recipe'));

// Expect ------------------------------------------------
// ------ Home Page --------------------------------------
expect(await screen.findByText('Recipes')).not.toBeNull();
expect(screen.queryByText('prep ahead')).toBeNull();
});
});
Loading