feat(recipes): return fail(422) when all ingredients filter to empty

Prevents a silent 400 from the backend when the user submits a form
where every ingredient row has quantity <= 0 or blank name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 09:36:41 +02:00
parent 56e6143fd2
commit ebaf42d83d
4 changed files with 63 additions and 18 deletions

View File

@@ -67,6 +67,13 @@ export const actions: Actions = {
return fail(400, { error: 'Ungültige Formulardaten' }); return fail(400, { error: 'Ungültige Formulardaten' });
} }
const filteredIngredients = (
parsedIngredients as { name: string; quantity: string; unit: string }[]
).filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0);
if (!filteredIngredients.length)
return fail(422, { error: 'Mindestens eine gültige Zutat ist erforderlich' });
const api = apiClient(fetch); const api = apiClient(fetch);
const { error: apiError } = await api.PUT('/v1/recipes/{id}', { const { error: apiError } = await api.PUT('/v1/recipes/{id}', {
params: { path: { id: params.id } }, params: { path: { id: params.id } },
@@ -76,14 +83,12 @@ export const actions: Actions = {
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null, cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null,
effort, effort,
heroImageUrl, heroImageUrl,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) ingredients: filteredIngredients.map((ing, i) => ({
.filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0) newIngredientName: ing.name.trim(),
.map((ing, i) => ({ quantity: Number(ing.quantity),
newIngredientName: ing.name.trim(), unit: ing.unit || '',
quantity: Number(ing.quantity), sortOrder: i
unit: ing.unit || '', })),
sortOrder: i
})),
steps: (parsedSteps as string[]) steps: (parsedSteps as string[])
.filter((s) => s?.trim()) .filter((s) => s?.trim())
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })), .map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),

View File

@@ -84,7 +84,7 @@ describe('edit recipe page — update action', () => {
name: 'Test Rezept', name: 'Test Rezept',
effort: 'easy', effort: 'easy',
tagIds: ['t1'], tagIds: ['t1'],
ingredientsJson: '[]', ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
stepsJson: '[]', stepsJson: '[]',
...overrides ...overrides
}; };
@@ -236,6 +236,24 @@ describe('edit recipe page — update action', () => {
expect(body.ingredients[0].newIngredientName).toBe('Spaghetti'); expect(body.ingredients[0].newIngredientName).toBe('Spaghetti');
}); });
it('returns fail(422) when all ingredients filter to empty after quantity check', async () => {
const result = await actions.update({
request: {
formData: async () =>
makeFormData({
ingredientsJson: JSON.stringify([
{ name: 'Salt', quantity: 0, unit: 'tsp' },
{ name: '', quantity: 100, unit: 'g' }
])
})
},
fetch: vi.fn(),
params: { id: 'r1' }
} as any);
expect(result.status).toBe(422);
expect(result.data.error).toMatch(/zutat/i);
});
it('returns fail(500) when API returns error', async () => { it('returns fail(500) when API returns error', async () => {
mockPut.mockResolvedValue({ error: { status: 500 } }); mockPut.mockResolvedValue({ error: { status: 500 } });
const result = await actions.update({ const result = await actions.update({

View File

@@ -40,6 +40,13 @@ export const actions: Actions = {
return fail(400, { error: 'Ungültige Formulardaten' }); return fail(400, { error: 'Ungültige Formulardaten' });
} }
const filteredIngredients = (
parsedIngredients as { name: string; quantity: string; unit: string }[]
).filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0);
if (!filteredIngredients.length)
return fail(422, { error: 'Mindestens eine gültige Zutat ist erforderlich' });
const api = apiClient(fetch); const api = apiClient(fetch);
const { error: apiError } = await api.POST('/v1/recipes', { const { error: apiError } = await api.POST('/v1/recipes', {
body: { body: {
@@ -48,14 +55,12 @@ export const actions: Actions = {
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null, cookTimeMin: cookTimeMin ? Number(cookTimeMin) || null : null,
effort, effort,
heroImageUrl, heroImageUrl,
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[]) ingredients: filteredIngredients.map((ing, i) => ({
.filter((ing) => ing.name?.trim() && Number(ing.quantity) > 0) newIngredientName: ing.name.trim(),
.map((ing, i) => ({ quantity: Number(ing.quantity),
newIngredientName: ing.name.trim(), unit: ing.unit || '',
quantity: Number(ing.quantity), sortOrder: i
unit: ing.unit || '', })),
sortOrder: i
})),
steps: (parsedSteps as string[]) steps: (parsedSteps as string[])
.filter((s) => s?.trim()) .filter((s) => s?.trim())
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })), .map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),

View File

@@ -62,7 +62,7 @@ describe('new recipe page — create action', () => {
name: 'Test Rezept', name: 'Test Rezept',
effort: 'easy', effort: 'easy',
tagIds: ['t1'], tagIds: ['t1'],
ingredientsJson: '[]', ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
stepsJson: '[]', stepsJson: '[]',
...overrides ...overrides
}; };
@@ -189,6 +189,23 @@ describe('new recipe page — create action', () => {
expect(body.ingredients[0].newIngredientName).toBe('Spaghetti'); expect(body.ingredients[0].newIngredientName).toBe('Spaghetti');
}); });
it('returns fail(422) when all ingredients filter to empty after quantity check', async () => {
const result = await actions.create({
request: {
formData: async () =>
makeFormData({
ingredientsJson: JSON.stringify([
{ name: 'Salt', quantity: 0, unit: 'tsp' },
{ name: '', quantity: 100, unit: 'g' }
])
})
},
fetch: vi.fn()
} as any);
expect(result.status).toBe(422);
expect(result.data.error).toMatch(/zutat/i);
});
it('returns fail(500) when API returns error', async () => { it('returns fail(500) when API returns error', async () => {
mockPost.mockResolvedValue({ error: { status: 500 } }); mockPost.mockResolvedValue({ error: { status: 500 } });
const result = await actions.create({ const result = await actions.create({