test(recipes): add action tests and harden create/update form actions
- Add try-catch around JSON.parse with fail(400) for malformed input - Validate effort against allowed values ['Easy','Medium','Hard'] - Fix NaN risk: Number(serves)||undefined instead of Number(serves) - Add action tests for create/update: validation, JSON.parse crash, success, API error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ import { error, redirect, fail } from '@sveltejs/kit';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { apiClient } from '$lib/server/api';
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||||
const api = apiClient(fetch);
|
const api = apiClient(fetch);
|
||||||
const [recipeResult, tagsResult] = await Promise.all([
|
const [recipeResult, tagsResult] = await Promise.all([
|
||||||
@@ -53,31 +55,38 @@ export const actions: Actions = {
|
|||||||
const tagIds = formData.getAll('tagIds') as string[];
|
const tagIds = formData.getAll('tagIds') as string[];
|
||||||
|
|
||||||
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
||||||
if (!effort) return fail(422, { error: 'Schwierigkeitsgrad ist erforderlich' });
|
if (!effort || !VALID_EFFORTS.includes(effort))
|
||||||
|
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
|
||||||
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
||||||
|
|
||||||
const parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
let parsedIngredients: unknown[];
|
||||||
const parsedSteps = JSON.parse(stepsJson || '[]');
|
let parsedSteps: unknown[];
|
||||||
|
try {
|
||||||
|
parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
||||||
|
parsedSteps = JSON.parse(stepsJson || '[]');
|
||||||
|
} catch {
|
||||||
|
return fail(400, { error: 'Ungültige Formulardaten' });
|
||||||
|
}
|
||||||
|
|
||||||
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 } },
|
||||||
body: {
|
body: {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
serves: serves ? Number(serves) : undefined,
|
serves: serves ? Number(serves) || undefined : undefined,
|
||||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) : undefined,
|
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
|
||||||
effort,
|
effort,
|
||||||
ingredients: parsedIngredients
|
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
|
||||||
.filter((ing: { name: string }) => ing.name?.trim())
|
.filter((ing) => ing.name?.trim())
|
||||||
.map((ing: { name: string; quantity: string; unit: string }, i: number) => ({
|
.map((ing, i) => ({
|
||||||
newIngredientName: ing.name.trim(),
|
newIngredientName: ing.name.trim(),
|
||||||
quantity: Number(ing.quantity) || 0,
|
quantity: Number(ing.quantity) || 0,
|
||||||
unit: ing.unit || '',
|
unit: ing.unit || '',
|
||||||
sortOrder: i
|
sortOrder: i
|
||||||
})),
|
})),
|
||||||
steps: parsedSteps
|
steps: (parsedSteps as string[])
|
||||||
.filter((s: string) => s?.trim())
|
.filter((s) => s?.trim())
|
||||||
.map((s: string, i: number) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
||||||
tagIds
|
tagIds
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,3 +75,116 @@ describe('edit recipe page — load', () => {
|
|||||||
).rejects.toMatchObject({ status: 404 });
|
).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('edit recipe page — update action', () => {
|
||||||
|
let actions: any;
|
||||||
|
|
||||||
|
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
|
||||||
|
const base: Record<string, string | string[]> = {
|
||||||
|
name: 'Test Rezept',
|
||||||
|
effort: 'Easy',
|
||||||
|
tagIds: ['t1'],
|
||||||
|
ingredientsJson: '[]',
|
||||||
|
stepsJson: '[]',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, val] of Object.entries(base)) {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
for (const v of val) fd.append(key, v);
|
||||||
|
} else {
|
||||||
|
fd.append(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPut.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
actions = mod.actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(422) when name is missing', async () => {
|
||||||
|
const result = await actions.update({
|
||||||
|
request: { formData: async () => makeFormData({ name: '' }) },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { id: 'r1' }
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(422) when effort is missing', async () => {
|
||||||
|
const result = await actions.update({
|
||||||
|
request: { formData: async () => makeFormData({ effort: '' }) },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { id: 'r1' }
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(422) when effort is not a valid value', async () => {
|
||||||
|
const result = await actions.update({
|
||||||
|
request: { formData: async () => makeFormData({ effort: 'VeryHard' }) },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { id: 'r1' }
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(422) when no tagIds', async () => {
|
||||||
|
const result = await actions.update({
|
||||||
|
request: { formData: async () => makeFormData({ tagIds: [] }) },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { id: 'r1' }
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(400) when ingredientsJson is invalid JSON', async () => {
|
||||||
|
const result = await actions.update({
|
||||||
|
request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { id: 'r1' }
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(400) when stepsJson is invalid JSON', async () => {
|
||||||
|
const result = await actions.update({
|
||||||
|
request: { formData: async () => makeFormData({ stepsJson: '{broken' }) },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { id: 'r1' }
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls PUT /v1/recipes/{id} with correct body on success', async () => {
|
||||||
|
mockPut.mockResolvedValue({ error: undefined });
|
||||||
|
const fd = makeFormData({
|
||||||
|
ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
|
||||||
|
stepsJson: JSON.stringify(['Kochen'])
|
||||||
|
});
|
||||||
|
await actions.update({
|
||||||
|
request: { formData: async () => fd },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { id: 'r1' }
|
||||||
|
} as any).catch(() => {});
|
||||||
|
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||||
|
params: { path: { id: 'r1' } },
|
||||||
|
body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' })
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(500) when API returns error', async () => {
|
||||||
|
mockPut.mockResolvedValue({ error: { status: 500 } });
|
||||||
|
const result = await actions.update({
|
||||||
|
request: { formData: async () => makeFormData() },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
params: { id: 'r1' }
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { redirect, fail } from '@sveltejs/kit';
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { apiClient } from '$lib/server/api';
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
const api = apiClient(fetch);
|
const api = apiClient(fetch);
|
||||||
const { data, error } = await api.GET('/v1/tags', {});
|
const { data, error } = await api.GET('/v1/tags', {});
|
||||||
@@ -26,30 +28,37 @@ export const actions: Actions = {
|
|||||||
const tagIds = formData.getAll('tagIds') as string[];
|
const tagIds = formData.getAll('tagIds') as string[];
|
||||||
|
|
||||||
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
||||||
if (!effort) return fail(422, { error: 'Schwierigkeitsgrad ist erforderlich' });
|
if (!effort || !VALID_EFFORTS.includes(effort))
|
||||||
|
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
|
||||||
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
||||||
|
|
||||||
const parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
let parsedIngredients: unknown[];
|
||||||
const parsedSteps = JSON.parse(stepsJson || '[]');
|
let parsedSteps: unknown[];
|
||||||
|
try {
|
||||||
|
parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
||||||
|
parsedSteps = JSON.parse(stepsJson || '[]');
|
||||||
|
} catch {
|
||||||
|
return fail(400, { error: 'Ungültige Formulardaten' });
|
||||||
|
}
|
||||||
|
|
||||||
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: {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
serves: serves ? Number(serves) : undefined,
|
serves: serves ? Number(serves) || undefined : undefined,
|
||||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) : undefined,
|
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
|
||||||
effort,
|
effort,
|
||||||
ingredients: parsedIngredients
|
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
|
||||||
.filter((ing: { name: string }) => ing.name?.trim())
|
.filter((ing) => ing.name?.trim())
|
||||||
.map((ing: { name: string; quantity: string; unit: string }, i: number) => ({
|
.map((ing, i) => ({
|
||||||
newIngredientName: ing.name.trim(),
|
newIngredientName: ing.name.trim(),
|
||||||
quantity: Number(ing.quantity) || 0,
|
quantity: Number(ing.quantity) || 0,
|
||||||
unit: ing.unit || '',
|
unit: ing.unit || '',
|
||||||
sortOrder: i
|
sortOrder: i
|
||||||
})),
|
})),
|
||||||
steps: parsedSteps
|
steps: (parsedSteps as string[])
|
||||||
.filter((s: string) => s?.trim())
|
.filter((s) => s?.trim())
|
||||||
.map((s: string, i: number) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
||||||
tagIds
|
tagIds
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,3 +51,104 @@ describe('new recipe page — load', () => {
|
|||||||
expect(result.recipe).toBeNull();
|
expect(result.recipe).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('new recipe page — create action', () => {
|
||||||
|
let actions: any;
|
||||||
|
|
||||||
|
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
|
||||||
|
const base: Record<string, string | string[]> = {
|
||||||
|
name: 'Test Rezept',
|
||||||
|
effort: 'Easy',
|
||||||
|
tagIds: ['t1'],
|
||||||
|
ingredientsJson: '[]',
|
||||||
|
stepsJson: '[]',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, val] of Object.entries(base)) {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
for (const v of val) fd.append(key, v);
|
||||||
|
} else {
|
||||||
|
fd.append(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
actions = mod.actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(422) when name is missing', async () => {
|
||||||
|
const result = await actions.create({
|
||||||
|
request: { formData: async () => makeFormData({ name: '' }) },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(422) when effort is missing', async () => {
|
||||||
|
const result = await actions.create({
|
||||||
|
request: { formData: async () => makeFormData({ effort: '' }) },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(422) when effort is not a valid value', async () => {
|
||||||
|
const result = await actions.create({
|
||||||
|
request: { formData: async () => makeFormData({ effort: 'InvalidEffort' }) },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(422) when no tagIds', async () => {
|
||||||
|
const result = await actions.create({
|
||||||
|
request: { formData: async () => makeFormData({ tagIds: [] }) },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(400) when ingredientsJson is invalid JSON', async () => {
|
||||||
|
const result = await actions.create({
|
||||||
|
request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(400) when stepsJson is invalid JSON', async () => {
|
||||||
|
const result = await actions.create({
|
||||||
|
request: { formData: async () => makeFormData({ stepsJson: '{broken' }) },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls POST /v1/recipes with correct body on success', async () => {
|
||||||
|
mockPost.mockResolvedValue({ error: undefined });
|
||||||
|
const fd = makeFormData({
|
||||||
|
ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
|
||||||
|
stepsJson: JSON.stringify(['Kochen'])
|
||||||
|
});
|
||||||
|
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(500) when API returns error', async () => {
|
||||||
|
mockPost.mockResolvedValue({ error: { status: 500 } });
|
||||||
|
const result = await actions.create({
|
||||||
|
request: { formData: async () => makeFormData() },
|
||||||
|
fetch: vi.fn()
|
||||||
|
} as any);
|
||||||
|
expect(result.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user