Skip to content
34 changes: 34 additions & 0 deletions api/src/models/Recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,38 @@ const instructionSubsection = new Schema({
},
});

interface RecipeYield {
quantity?: string;
unit?: Types.ObjectId;
}
const recipeYieldSchema = new Schema<RecipeYield>({
quantity: {
type: String,
validate: {
validator: function (quantity: string) {
if (quantity != null && !quantityRegex.test(quantity)) {
return false;
}
return true;
},
message: 'Invalid yield quantity format',
},
},
unit: {
type: Schema.Types.ObjectId,
ref: 'Unit',
validate: {
validator: function (unit: Types.ObjectId) {
if (unit != null) {
return Unit.exists({ _id: unit });
}
return true;
},
message: 'Yield unit does not exist.',
},
},
});

export interface Recipe extends Document {
title: string;
titleIdentifier: string;
Expand All @@ -161,6 +193,7 @@ export interface Recipe extends Document {
owner: Types.ObjectId;
source?: string;
numServings: number;
yield?: RecipeYield;
isIngredient: boolean;
createdAt: Date;
lastModified: Date;
Expand Down Expand Up @@ -249,6 +282,7 @@ const recipeSchema = new Schema<Recipe>({
owner: { type: Schema.Types.ObjectId, required: true, ref: 'User', validate: ownerExists() },
source: { type: String },
numServings: { type: Number, required: true },
yield: { type: recipeYieldSchema },
isIngredient: { type: Boolean, required: true },
createdAt: { type: Date, required: true },
lastModified: { type: Date, required: true },
Expand Down
17 changes: 17 additions & 0 deletions api/src/schema/Recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,23 @@ RecipeTC.addFields({
},
},
});
RecipeTC.extendField('yield', {
type: new GraphQLObjectType({
name: 'RecipeYield',
fields: {
quantity: { type: GraphQLString },
unit: {
type: UnitTC.getType(),
resolve: async (source) => {
if (!source.unit) return null;
return UnitTC.mongooseResolvers
.findById()
.resolve({ args: { _id: source.unit } });
},
},
},
}),
});

export const RecipeQuery = {
recipeById: RecipeTC.mongooseResolvers
Expand Down
15 changes: 15 additions & 0 deletions api/src/scripts/updateSchema_2026-03-29.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* 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);

// Set yield: null on all existing recipe documents that don't have the field
const result = db.recipes.updateMany({ yield: { $exists: false } }, { $set: { yield: null } });

print(`Migration complete. Updated ${result.modifiedCount} document(s).`);

// Example usage:
// COLLECTION=recipeProdBackup mongosh "mongodb://localhost:27017" updateSchema_2026-03-29.js
173 changes: 173 additions & 0 deletions api/test/graphql/Recipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,115 @@ describe('recipeCreateOne', () => {
);
});

it('should create a recipe with yield quantity and unit', 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 yieldUnit = await Unit.findOne({ shortSingular: 'cup' });
const newRecord = {
...getDefaultRecipeRecord(ingredient, unit, prepMethod),
yield: { quantity: '2', unit: yieldUnit._id },
};
const query = `
mutation RecipeCreateOne($record: CreateOneRecipeCreateInput!) {
recipeCreateOne(record: $record) {
record {
_id
title
yield {
quantity
unit {
shortSingular
}
}
}
}
}`;
const response = await this.apolloServer.executeOperation(
{ query, variables: { record: newRecord } },
{ contextValue: { isAuthenticated: () => true, getUser: () => user } }
);
assert.equal(response.body.kind, 'single');
assert.isUndefined(
response.body.singleResult.errors,
response.body.singleResult.errors ? response.body.singleResult.errors[0].message : ''
);
const record = (response.body.singleResult.data as any).recipeCreateOne.record;
assert.equal(record.yield.quantity, '2');
assert.equal(record.yield.unit.shortSingular, 'cup');
});

it('should create a recipe with yield quantity only (no unit)', 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),
yield: { quantity: '12' },
};
const query = `
mutation RecipeCreateOne($record: CreateOneRecipeCreateInput!) {
recipeCreateOne(record: $record) {
record {
_id
yield {
quantity
unit {
shortSingular
}
}
}
}
}`;
const response = await this.apolloServer.executeOperation(
{ query, variables: { record: newRecord } },
{ contextValue: { isAuthenticated: () => true, getUser: () => user } }
);
assert.equal(response.body.kind, 'single');
assert.isUndefined(
response.body.singleResult.errors,
response.body.singleResult.errors ? response.body.singleResult.errors[0].message : ''
);
const record = (response.body.singleResult.data as any).recipeCreateOne.record;
assert.equal(record.yield.quantity, '12');
assert.isNull(record.yield.unit);
});

it('should NOT create a recipe with invalid yield quantity', 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),
yield: { quantity: 'abc' },
};
const response = await createRecipe(this, user, newRecord);
assert.equal(response.body.kind, 'single');
assert.isDefined(response.body.singleResult.errors, 'Validation error should occur');
assert.include(response.body.singleResult.errors[0].message, 'Invalid quantity format');
});

it('should NOT create a recipe with non-existent yield unit', 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),
yield: { quantity: '2', unit: 'nonexistent-unit-id' },
};
const response = await createRecipe(this, user, newRecord);
assert.equal(response.body.kind, 'single');
assert.isDefined(response.body.singleResult.errors, 'Validation error should occur');
assert.equal(
response.body.singleResult.errors[0].message,
'Recipe validation failed: yield: Yield unit does not exist.'
);
});

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 @@ -1196,6 +1305,70 @@ describe('recipeUpdateById', () => {
assert.equal(updatedRecipe.numServings, 6);
assert.equal(updatedRecipe.notes, 'Updated notes');
});

it('should update recipe yield quantity and unit', 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 yieldUnit = await Unit.findOne({ shortSingular: 'cup' });
const newRecipe = new Recipe(getDefaultRecipe(user, ingredient, unit, prepMethod));
const recipe = await newRecipe.save();

const updateQuery = `
mutation RecipeUpdateById($id: MongoID!, $record: UpdateByIdRecipeModifyInput!) {
recipeUpdateById(_id: $id, record: $record) {
record {
_id
yield {
quantity
unit {
shortSingular
}
}
}
}
}`;
const response = await this.apolloServer.executeOperation(
{
query: updateQuery,
variables: {
id: recipe._id,
record: { yield: { quantity: '3/2', unit: yieldUnit._id } },
},
},
{ contextValue: { isAuthenticated: () => true, getUser: () => user } }
);
assert.equal(response.body.kind, 'single');
assert.isUndefined(
response.body.singleResult.errors,
response.body.singleResult.errors ? response.body.singleResult.errors[0].message : ''
);
const record = (response.body.singleResult.data as any).recipeUpdateById.record;
assert.equal(record.yield.quantity, '3/2');
assert.equal(record.yield.unit.shortSingular, 'cup');
});

it('should clear recipe yield by setting it to null', 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 yieldUnit = await Unit.findOne({ shortSingular: 'cup' });
const recipeData = {
...getDefaultRecipe(user, ingredient, unit, prepMethod),
yield: { quantity: '4', unit: yieldUnit._id },
};
const newRecipe = new Recipe(recipeData);
const recipe = await newRecipe.save();
assert.isNotNull(recipe.yield);

const response = await updateRecipe(this, user, recipe._id, { yield: null });
const record = parseUpdatedRecipe(response);
assert.equal(record.title, 'Chicken Soup');
const updatedDoc = await Recipe.findById(recipe._id);
assert.isNull(updatedDoc.yield);
});
});

describe('recipeRemoveById', () => {
Expand Down
Loading