feat(suggestions): C2 — Meal suggestions (variety-aware) (#40)
feat(suggestions): implement C2 meal suggestion screen (Issue #27) Co-authored-by: Marcel Raddatz <marcel@raddatz.cloud> Co-committed-by: Marcel Raddatz <marcel@raddatz.cloud>
This commit was merged in pull request #40.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
||||
}));
|
||||
|
||||
describe('suggestions page — load', () => {
|
||||
let load: any;
|
||||
|
||||
const mockSuggestions = {
|
||||
suggestions: [
|
||||
{
|
||||
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
|
||||
simulatedScore: 9.2
|
||||
},
|
||||
{
|
||||
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
|
||||
simulatedScore: 6.1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r3', name: 'Pasta', effort: 'Easy' } }
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
it('fetches suggestions for the given plan and day', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
|
||||
params: expect.objectContaining({ path: { id: 'plan-1' } })
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns suggestions list sorted by simulatedScore descending', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.suggestions[0].recipe.name).toBe('Pasta al Limone');
|
||||
expect(result.suggestions[1].recipe.name).toBe('Hühnchen Curry');
|
||||
});
|
||||
|
||||
it('returns the selectedDay from URL params', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.selectedDay).toBe('2026-04-01');
|
||||
});
|
||||
|
||||
it('returns empty suggestions when API fails', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns week plan slots for context display', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null weekPlan and empty suggestions when week plan not found', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
expect(result.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults day to weekStart when no day param provided', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.selectedDay).toBe('2026-03-30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestions page — pickSuggestion action', () => {
|
||||
let actions: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('adds a slot to the week plan via POST and redirects to planner', async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined });
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
try {
|
||||
await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.status).toBe(303);
|
||||
expect(e.location).toBe('/planner?week=2026-03-30');
|
||||
}
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({
|
||||
params: { path: { id: 'plan-1' } },
|
||||
body: { slotDate: '2026-04-01', recipeId: '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f' }
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns error when planId is missing', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', '');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error for invalid recipeId format', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', 'not-a-uuid');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Ungültige Rezept-ID.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when API fails', async () => {
|
||||
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
||||
});
|
||||
|
||||
it('returns permission error for member role', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error for invalid slotDate format', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', 'not-a-date');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user