fix(frontend): address all PR review concerns
- Fix 7px → 11px font-size on section headers in RecipePicker - Extract shared slotActions.ts with UUID validation for planId/slotId/recipeId - Load full recipe list in planner page load (was placeholder current-week slots) - Update planner/+page.svelte to pass data.recipes as allRecipes to RecipePicker - Update planner and recipes page.server.ts to use shared slot action helpers - Fix planner page.server tests: add recipes mock for parallel GET load - Update action tests to use valid UUIDs (were 'plan-1'/'r1' style strings) - Add validation-path tests for blank/invalid input on all slot actions - Add tests for recipes/+server.ts GET endpoint (DayPicker week navigation) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@
|
||||
<!-- Empfohlen section -->
|
||||
{#if suggestions.length > 0}
|
||||
<div
|
||||
style="font-size: 7px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Empfohlen · Beste Abwechslung
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
<!-- Alle Rezepte section -->
|
||||
<div
|
||||
style="font-size: 7px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Alle Rezepte
|
||||
</div>
|
||||
|
||||
65
frontend/src/lib/server/slotActions.ts
Normal file
65
frontend/src/lib/server/slotActions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isValidUuid(value: string | null): value is string {
|
||||
return typeof value === 'string' && UUID_RE.test(value);
|
||||
}
|
||||
|
||||
export async function addSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string | null;
|
||||
const slotDate = formData.get('slotDate') as string | null;
|
||||
const recipeId = formData.get('recipeId') as string | null;
|
||||
|
||||
if (!isValidUuid(planId) || !isValidUuid(recipeId) || !slotDate) {
|
||||
return { success: false, error: 'Ungültige Eingabe.' };
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
||||
params: { path: { id: planId } },
|
||||
body: { slotDate, recipeId }
|
||||
});
|
||||
|
||||
if (error || !data) return { success: false };
|
||||
return { success: true, slot: data };
|
||||
}
|
||||
|
||||
export async function updateSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string | null;
|
||||
const slotId = formData.get('slotId') as string | null;
|
||||
const recipeId = formData.get('recipeId') as string | null;
|
||||
|
||||
if (!isValidUuid(planId) || !isValidUuid(slotId) || !isValidUuid(recipeId)) {
|
||||
return { success: false, error: 'Ungültige Eingabe.' };
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||
params: { path: { planId, slotId } },
|
||||
body: { recipeId }
|
||||
});
|
||||
|
||||
if (error || !data) return { success: false };
|
||||
return { success: true, slot: data };
|
||||
}
|
||||
|
||||
export async function deleteSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string | null;
|
||||
const slotId = formData.get('slotId') as string | null;
|
||||
|
||||
if (!isValidUuid(planId) || !isValidUuid(slotId)) {
|
||||
return { success: false, error: 'Ungültige Eingabe.' };
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||
params: { path: { planId, slotId } }
|
||||
});
|
||||
|
||||
if (error) return { success: false };
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,77 +1,50 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const weekParam = url.searchParams.get('week');
|
||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data: weekPlan, error } = await api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
});
|
||||
const [weekPlanResult, recipesResult] = await Promise.all([
|
||||
api.GET('/v1/week-plans', { params: { query: { weekStart } } }),
|
||||
api.GET('/v1/recipes', {})
|
||||
]);
|
||||
|
||||
if (error || !weekPlan?.id) {
|
||||
return { weekPlan: null, varietyScore: null, weekStart };
|
||||
const recipes =
|
||||
recipesResult.error || !recipesResult.data?.data
|
||||
? []
|
||||
: recipesResult.data.data.map((r: any) => ({
|
||||
id: r.id!,
|
||||
name: r.name!,
|
||||
cookTimeMin: r.cookTimeMin,
|
||||
effort: r.effort,
|
||||
heroImageUrl: r.heroImageUrl
|
||||
}));
|
||||
|
||||
if (weekPlanResult.error || !weekPlanResult.data?.id) {
|
||||
return { weekPlan: null, varietyScore: null, weekStart, recipes };
|
||||
}
|
||||
|
||||
const weekPlan = weekPlanResult.data;
|
||||
const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
|
||||
params: { path: { id: weekPlan.id } }
|
||||
params: { path: { id: weekPlan.id! } }
|
||||
});
|
||||
|
||||
return {
|
||||
weekPlan,
|
||||
varietyScore: varietyScore ?? null,
|
||||
weekStart
|
||||
weekStart,
|
||||
recipes
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addSlot: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string;
|
||||
const slotDate = formData.get('slotDate') as string;
|
||||
const recipeId = formData.get('recipeId') as string;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
||||
params: { path: { id: planId } },
|
||||
body: { slotDate, recipeId }
|
||||
});
|
||||
|
||||
if (error || !data) return { success: false };
|
||||
return { success: true, slot: data };
|
||||
},
|
||||
|
||||
updateSlot: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string;
|
||||
const slotId = formData.get('slotId') as string;
|
||||
const recipeId = formData.get('recipeId') as string;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||
params: { path: { planId, slotId } },
|
||||
body: { recipeId }
|
||||
});
|
||||
|
||||
if (error || !data) return { success: false };
|
||||
return { success: true, slot: data };
|
||||
},
|
||||
|
||||
deleteSlot: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string;
|
||||
const slotId = formData.get('slotId') as string;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||
params: { path: { planId, slotId } }
|
||||
});
|
||||
|
||||
if (error) return { success: false };
|
||||
return { success: true };
|
||||
},
|
||||
addSlot: addSlotAction,
|
||||
updateSlot: updateSlotAction,
|
||||
deleteSlot: deleteSlotAction,
|
||||
|
||||
createPlan: async ({ fetch, request, locals }) => {
|
||||
// Role guard: only planners may create week plans
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
|
||||
|
||||
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string }; form?: any } = $props();
|
||||
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
|
||||
|
||||
// Use UTC date string (YYYY-MM-DD) consistently
|
||||
const today: string = new Date().toISOString().slice(0, 10);
|
||||
@@ -263,7 +263,7 @@
|
||||
dateLabel={formatDayLabel(selectedDay)}
|
||||
currentVarietyScore={varietyScore?.score ?? 0}
|
||||
suggestions={[]}
|
||||
allRecipes={weekPlan?.slots?.map((s: any) => s.recipe).filter(Boolean) ?? []}
|
||||
allRecipes={data.recipes}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</BottomSheet>
|
||||
@@ -495,7 +495,7 @@
|
||||
dateLabel={formatDayLabel(pickerDate)}
|
||||
currentVarietyScore={varietyScore?.score ?? 0}
|
||||
suggestions={[]}
|
||||
allRecipes={weekPlan?.slots?.map((s: any) => s.recipe).filter(Boolean) ?? []}
|
||||
allRecipes={data.recipes}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,22 @@ vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete })
|
||||
}));
|
||||
|
||||
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
|
||||
const SLOT_UUID = '22222222-2222-2222-2222-222222222222';
|
||||
const RECIPE_UUID = '33333333-3333-3333-3333-333333333333';
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: PLAN_UUID,
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: [
|
||||
{ id: SLOT_UUID, slotDate: '2026-03-30', recipe: { id: RECIPE_UUID, name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } }
|
||||
]
|
||||
};
|
||||
|
||||
const mockRecipes = [{ id: RECIPE_UUID, name: 'Pasta', cookTimeMin: 30, effort: 'Easy' }];
|
||||
const mockVarietyScore = { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] };
|
||||
|
||||
describe('planner page — load', () => {
|
||||
let load: any;
|
||||
|
||||
@@ -23,48 +39,44 @@ describe('planner page — load', () => {
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } }
|
||||
]
|
||||
};
|
||||
|
||||
it('fetches week plan for the current week by default', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] },
|
||||
error: undefined
|
||||
});
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) // weekPlan
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) // recipes
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); // varietyScore
|
||||
const url = new URL('http://localhost/planner');
|
||||
await load({ fetch: vi.fn(), url });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ weekStart: expect.any(String) }) }) }));
|
||||
});
|
||||
|
||||
it('uses weekStart from URL search params if provided', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 8 }, error: undefined });
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner?week=2026-03-30');
|
||||
await load({ fetch: vi.fn(), url });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: { weekStart: '2026-03-30' } }) }));
|
||||
});
|
||||
|
||||
it('returns weekPlan with slots in page data', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 7.5 }, error: undefined });
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.id).toBe('plan-1');
|
||||
expect(result.weekPlan.slots).toHaveLength(2);
|
||||
expect(result.weekPlan.id).toBe(PLAN_UUID);
|
||||
expect(result.weekPlan.slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns variety score in page data', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }, error: undefined });
|
||||
const scoreWithOverlap = { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] };
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: scoreWithOverlap, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.varietyScore.score).toBe(7.5);
|
||||
@@ -72,7 +84,9 @@ describe('planner page — load', () => {
|
||||
});
|
||||
|
||||
it('returns null weekPlan when API returns 404', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }) // weekPlan 404
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }); // recipes
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
@@ -80,8 +94,10 @@ describe('planner page — load', () => {
|
||||
});
|
||||
|
||||
it('returns the weekStart used for the query', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 6 }, error: undefined });
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekStart).toBe('2026-03-30');
|
||||
@@ -89,11 +105,34 @@ describe('planner page — load', () => {
|
||||
|
||||
it('creates week plan if not found and fetches variety score after creation', async () => {
|
||||
// When no plan exists (404), load should return null weekPlan — creation is done via a POST action, not in load
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: undefined, error: { status: 404 } })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
});
|
||||
|
||||
it('returns recipes in page data', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.recipes).toHaveLength(1);
|
||||
expect(result.recipes[0].name).toBe('Pasta');
|
||||
});
|
||||
|
||||
it('returns empty recipes array when recipes API fails', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }) // recipes fail
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.recipes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner page — actions', () => {
|
||||
@@ -108,7 +147,7 @@ describe('planner page — actions', () => {
|
||||
});
|
||||
|
||||
it('createPlan action calls POST /v1/week-plans', async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: 'plan-new', weekStart: '2026-03-30', slots: [] }, error: undefined });
|
||||
mockPost.mockResolvedValue({ data: { id: PLAN_UUID, weekStart: '2026-03-30', slots: [] }, error: undefined });
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
@@ -178,20 +217,15 @@ describe('planner page — variety score partial failure', () => {
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: []
|
||||
};
|
||||
|
||||
it('returns weekPlan even when variety score API fails', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); // variety score fails
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.id).toBe('plan-1');
|
||||
expect(result.weekPlan.id).toBe(PLAN_UUID);
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -211,10 +245,10 @@ describe('planner page — slot actions', () => {
|
||||
|
||||
it('addSlot calls POST /v1/week-plans/{id}/slots and returns success with slot', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', 'r1');
|
||||
mockPost.mockResolvedValue({ data: { id: 's1', slotDate: '2026-04-01' }, error: undefined });
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPost.mockResolvedValue({ data: { id: SLOT_UUID, slotDate: '2026-04-01' }, error: undefined });
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
@@ -222,19 +256,19 @@ describe('planner page — slot actions', () => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{id}/slots',
|
||||
expect.objectContaining({
|
||||
params: { path: { id: 'plan-1' } },
|
||||
body: { slotDate: '2026-04-01', recipeId: 'r1' }
|
||||
params: { path: { id: PLAN_UUID } },
|
||||
body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.slot?.id).toBe('s1');
|
||||
expect(result.slot?.id).toBe(SLOT_UUID);
|
||||
});
|
||||
|
||||
it('addSlot returns failure when API errors', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', 'r1');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
@@ -243,12 +277,39 @@ describe('planner page — slot actions', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('addSlot returns validation error when planId is not a UUID', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'not-a-uuid');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addSlot returns validation error when slotDate is missing', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('slotId', 's1');
|
||||
formData.set('recipeId', 'r2');
|
||||
mockPatch.mockResolvedValue({ data: { id: 's1' }, error: undefined });
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', SLOT_UUID);
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPatch.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined });
|
||||
const result = await actions.updateSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
@@ -256,17 +317,31 @@ describe('planner page — slot actions', () => {
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||
expect.objectContaining({
|
||||
params: { path: { planId: 'plan-1', slotId: 's1' } },
|
||||
body: { recipeId: 'r2' }
|
||||
params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } },
|
||||
body: { recipeId: RECIPE_UUID }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('updateSlot returns validation error when slotId is not a UUID', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', 'bad-id');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.updateSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('slotId', 's1');
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', SLOT_UUID);
|
||||
mockDelete.mockResolvedValue({ error: undefined });
|
||||
const result = await actions.deleteSlot({
|
||||
fetch: vi.fn(),
|
||||
@@ -275,9 +350,21 @@ describe('planner page — slot actions', () => {
|
||||
expect(mockDelete).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||
expect.objectContaining({
|
||||
params: { path: { planId: 'plan-1', slotId: 's1' } }
|
||||
params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteSlot returns validation error when planId is missing', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('slotId', SLOT_UUID);
|
||||
const result = await actions.deleteSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const api = apiClient(fetch);
|
||||
@@ -29,49 +30,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addSlot: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string;
|
||||
const slotDate = formData.get('slotDate') as string;
|
||||
const recipeId = formData.get('recipeId') as string;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
||||
params: { path: { id: planId } },
|
||||
body: { slotDate, recipeId }
|
||||
});
|
||||
|
||||
if (error || !data) return { success: false };
|
||||
return { success: true, slot: data };
|
||||
},
|
||||
|
||||
updateSlot: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string;
|
||||
const slotId = formData.get('slotId') as string;
|
||||
const recipeId = formData.get('recipeId') as string;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||
params: { path: { planId, slotId } },
|
||||
body: { recipeId }
|
||||
});
|
||||
|
||||
if (error || !data) return { success: false };
|
||||
return { success: true, slot: data };
|
||||
},
|
||||
|
||||
deleteSlot: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string;
|
||||
const slotId = formData.get('slotId') as string;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||
params: { path: { planId, slotId } }
|
||||
});
|
||||
|
||||
if (error) return { success: false };
|
||||
return { success: true };
|
||||
}
|
||||
addSlot: addSlotAction,
|
||||
updateSlot: updateSlotAction,
|
||||
deleteSlot: deleteSlotAction
|
||||
};
|
||||
|
||||
@@ -16,6 +16,21 @@ vi.mock('$lib/planner/week', () => ({
|
||||
getWeekStart: () => '2026-04-07'
|
||||
}));
|
||||
|
||||
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
|
||||
const SLOT_UUID = '22222222-2222-2222-2222-222222222222';
|
||||
const RECIPE_UUID = '33333333-3333-3333-3333-333333333333';
|
||||
|
||||
const mockRecipes = [
|
||||
{ id: RECIPE_UUID, name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: '44444444-4444-4444-4444-444444444444', name: 'Curry', cookTimeMin: 45, effort: 'Medium' }
|
||||
];
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: PLAN_UUID,
|
||||
weekStart: '2026-04-07',
|
||||
slots: [{ id: SLOT_UUID, slotDate: '2026-04-07', recipe: { id: RECIPE_UUID, name: 'Pasta', effort: 'easy' } }]
|
||||
};
|
||||
|
||||
describe('recipe library page — load', () => {
|
||||
let load: any;
|
||||
|
||||
@@ -26,17 +41,6 @@ describe('recipe library page — load', () => {
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockRecipes = [
|
||||
{ id: 'r1', name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' }
|
||||
];
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-04-07',
|
||||
slots: [{ id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } }]
|
||||
};
|
||||
|
||||
it('fetches recipes from GET /v1/recipes', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
@@ -75,7 +79,7 @@ describe('recipe library page — load', () => {
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.activePlan.id).toBe('plan-1');
|
||||
expect(result.activePlan.id).toBe(PLAN_UUID);
|
||||
expect(result.activePlan.slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -103,10 +107,10 @@ describe('recipe library page — actions', () => {
|
||||
|
||||
it('addSlot calls POST /v1/week-plans/{id}/slots and returns success', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', 'r1');
|
||||
mockPost.mockResolvedValue({ data: { id: 's1' }, error: undefined });
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPost.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined });
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
@@ -114,8 +118,8 @@ describe('recipe library page — actions', () => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{id}/slots',
|
||||
expect.objectContaining({
|
||||
params: { path: { id: 'plan-1' } },
|
||||
body: { slotDate: '2026-04-01', recipeId: 'r1' }
|
||||
params: { path: { id: PLAN_UUID } },
|
||||
body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
@@ -123,9 +127,9 @@ describe('recipe library page — actions', () => {
|
||||
|
||||
it('addSlot returns error when API fails', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', 'r1');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
@@ -134,12 +138,38 @@ describe('recipe library page — actions', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||
it('addSlot returns validation error when planId is not a UUID', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('slotId', 's1');
|
||||
formData.set('recipeId', 'r1');
|
||||
mockPatch.mockResolvedValue({ data: { id: 's1' }, error: undefined });
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addSlot returns validation error when slotDate is missing', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', SLOT_UUID);
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPatch.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined });
|
||||
const result = await actions.updateSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
@@ -147,17 +177,31 @@ describe('recipe library page — actions', () => {
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||
expect.objectContaining({
|
||||
params: { path: { planId: 'plan-1', slotId: 's1' } },
|
||||
body: { recipeId: 'r1' }
|
||||
params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } },
|
||||
body: { recipeId: RECIPE_UUID }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('updateSlot returns validation error when slotId is not a UUID', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', 's1');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.updateSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('slotId', 's1');
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', SLOT_UUID);
|
||||
mockDelete.mockResolvedValue({ error: undefined });
|
||||
const result = await actions.deleteSlot({
|
||||
fetch: vi.fn(),
|
||||
@@ -166,9 +210,21 @@ describe('recipe library page — actions', () => {
|
||||
expect(mockDelete).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||
expect.objectContaining({
|
||||
params: { path: { planId: 'plan-1', slotId: 's1' } }
|
||||
params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteSlot returns validation error when slotId is missing', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
const result = await actions.deleteSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
71
frontend/src/routes/(app)/recipes/server.test.ts
Normal file
71
frontend/src/routes/(app)/recipes/server.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet })
|
||||
}));
|
||||
|
||||
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: PLAN_UUID,
|
||||
weekStart: '2026-04-07',
|
||||
slots: []
|
||||
};
|
||||
|
||||
describe('GET /recipes (server route)', () => {
|
||||
let GET: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+server');
|
||||
GET = mod.GET;
|
||||
});
|
||||
|
||||
it('returns plan data when week param is provided and API succeeds', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockWeekPlan, error: undefined });
|
||||
const url = new URL('http://localhost/recipes?week=2026-04-07');
|
||||
const response = await GET({ fetch: vi.fn(), url } as any);
|
||||
const body = await response.json();
|
||||
expect(body.plan).toBeDefined();
|
||||
expect(body.plan.id).toBe(PLAN_UUID);
|
||||
});
|
||||
|
||||
it('returns { plan: null } when week param is missing', async () => {
|
||||
const url = new URL('http://localhost/recipes');
|
||||
const response = await GET({ fetch: vi.fn(), url } as any);
|
||||
const body = await response.json();
|
||||
expect(body.plan).toBeNull();
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns { plan: null } when API returns an error', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/recipes?week=2026-04-07');
|
||||
const response = await GET({ fetch: vi.fn(), url } as any);
|
||||
const body = await response.json();
|
||||
expect(body.plan).toBeNull();
|
||||
});
|
||||
|
||||
it('returns { plan: null } when API returns data without id', async () => {
|
||||
mockGet.mockResolvedValue({ data: { weekStart: '2026-04-07' }, error: undefined });
|
||||
const url = new URL('http://localhost/recipes?week=2026-04-07');
|
||||
const response = await GET({ fetch: vi.fn(), url } as any);
|
||||
const body = await response.json();
|
||||
expect(body.plan).toBeNull();
|
||||
});
|
||||
|
||||
it('calls GET /v1/week-plans with the provided week param', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockWeekPlan, error: undefined });
|
||||
const url = new URL('http://localhost/recipes?week=2026-04-14');
|
||||
await GET({ fetch: vi.fn(), url } as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({
|
||||
params: { query: { weekStart: '2026-04-14' } }
|
||||
}));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user