- 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>
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
vi.mock('$env/dynamic/private', () => ({
|
|
env: { BACKEND_URL: 'http://localhost:8080' }
|
|
}));
|
|
|
|
const mockGet = vi.fn();
|
|
const mockPost = vi.fn();
|
|
const mockPatch = vi.fn();
|
|
const mockDelete = vi.fn();
|
|
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;
|
|
|
|
beforeEach(async () => {
|
|
mockGet.mockReset();
|
|
mockPost.mockReset();
|
|
vi.resetModules();
|
|
const mod = await import('./+page.server');
|
|
load = mod.load;
|
|
});
|
|
|
|
it('fetches week plan for the current week by default', async () => {
|
|
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 })
|
|
.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 })
|
|
.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_UUID);
|
|
expect(result.weekPlan.slots).toHaveLength(1);
|
|
});
|
|
|
|
it('returns variety score in page data', async () => {
|
|
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);
|
|
expect(result.varietyScore.ingredientOverlaps).toHaveLength(1);
|
|
});
|
|
|
|
it('returns null weekPlan when API returns 404', async () => {
|
|
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();
|
|
expect(result.varietyScore).toBeNull();
|
|
});
|
|
|
|
it('returns the weekStart used for the query', 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?week=2026-03-30');
|
|
const result = await load({ fetch: vi.fn(), url });
|
|
expect(result.weekStart).toBe('2026-03-30');
|
|
});
|
|
|
|
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 } })
|
|
.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', () => {
|
|
let actions: any;
|
|
|
|
beforeEach(async () => {
|
|
mockGet.mockReset();
|
|
mockPost.mockReset();
|
|
vi.resetModules();
|
|
const mod = await import('./+page.server');
|
|
actions = mod.actions;
|
|
});
|
|
|
|
it('createPlan action calls POST /v1/week-plans', async () => {
|
|
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({
|
|
fetch: vi.fn(),
|
|
request: { formData: async () => formData },
|
|
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
|
});
|
|
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ body: { weekStart: '2026-03-30' } }));
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
|
|
it('createPlan action returns error when API fails', async () => {
|
|
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
|
|
const formData = new FormData();
|
|
formData.set('weekStart', '2026-03-30');
|
|
const result = await actions.createPlan({
|
|
fetch: vi.fn(),
|
|
request: { formData: async () => formData },
|
|
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
|
});
|
|
expect(result).toEqual({ success: false, error: expect.any(String) });
|
|
});
|
|
|
|
it('createPlan action returns error for invalid weekStart format', async () => {
|
|
const formData = new FormData();
|
|
formData.set('weekStart', 'not-a-date');
|
|
const result = await actions.createPlan({
|
|
fetch: vi.fn(),
|
|
request: { formData: async () => formData },
|
|
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
|
});
|
|
expect(result).toEqual({ success: false, error: 'Ungültiges Datum.' });
|
|
expect(mockPost).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('createPlan action returns error when weekStart is missing', async () => {
|
|
const formData = new FormData();
|
|
const result = await actions.createPlan({
|
|
fetch: vi.fn(),
|
|
request: { formData: async () => formData },
|
|
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
|
});
|
|
expect(result).toEqual({ success: false, error: 'Ungültiges Datum.' });
|
|
});
|
|
|
|
it('createPlan action returns permission error for member role', async () => {
|
|
const formData = new FormData();
|
|
formData.set('weekStart', '2026-03-30');
|
|
const result = await actions.createPlan({
|
|
fetch: vi.fn(),
|
|
request: { formData: async () => formData },
|
|
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' }, haushalt: { id: 'h1', name: 'Test' } }
|
|
});
|
|
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
|
|
expect(mockPost).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('planner page — variety score partial failure', () => {
|
|
let load: any;
|
|
|
|
beforeEach(async () => {
|
|
mockGet.mockReset();
|
|
mockPost.mockReset();
|
|
vi.resetModules();
|
|
const mod = await import('./+page.server');
|
|
load = mod.load;
|
|
});
|
|
|
|
it('returns weekPlan even when variety score API fails', async () => {
|
|
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_UUID);
|
|
expect(result.varietyScore).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('planner page — slot actions', () => {
|
|
let actions: any;
|
|
|
|
beforeEach(async () => {
|
|
mockGet.mockReset();
|
|
mockPost.mockReset();
|
|
mockPatch.mockReset();
|
|
mockDelete.mockReset();
|
|
vi.resetModules();
|
|
const mod = await import('./+page.server');
|
|
actions = mod.actions;
|
|
});
|
|
|
|
it('addSlot calls POST /v1/week-plans/{id}/slots and returns success with slot', async () => {
|
|
const formData = new FormData();
|
|
formData.set('planId', PLAN_UUID);
|
|
formData.set('slotDate', '2026-04-01');
|
|
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 }
|
|
} as any);
|
|
expect(mockPost).toHaveBeenCalledWith(
|
|
'/v1/week-plans/{id}/slots',
|
|
expect.objectContaining({
|
|
params: { path: { id: PLAN_UUID } },
|
|
body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID }
|
|
})
|
|
);
|
|
expect(result.success).toBe(true);
|
|
expect(result.slot?.id).toBe(SLOT_UUID);
|
|
});
|
|
|
|
it('addSlot returns failure when API errors', async () => {
|
|
const formData = new FormData();
|
|
formData.set('planId', PLAN_UUID);
|
|
formData.set('slotDate', '2026-04-01');
|
|
formData.set('recipeId', RECIPE_UUID);
|
|
mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
|
const result = await actions.addSlot({
|
|
fetch: vi.fn(),
|
|
request: { formData: async () => formData }
|
|
} as any);
|
|
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_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 }
|
|
} as any);
|
|
expect(mockPatch).toHaveBeenCalledWith(
|
|
'/v1/week-plans/{planId}/slots/{slotId}',
|
|
expect.objectContaining({
|
|
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_UUID);
|
|
formData.set('slotId', SLOT_UUID);
|
|
mockDelete.mockResolvedValue({ error: undefined });
|
|
const result = await actions.deleteSlot({
|
|
fetch: vi.fn(),
|
|
request: { formData: async () => formData }
|
|
} as any);
|
|
expect(mockDelete).toHaveBeenCalledWith(
|
|
'/v1/week-plans/{planId}/slots/{slotId}',
|
|
expect.objectContaining({
|
|
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();
|
|
});
|
|
});
|