+
+
← Einstellungen
Vorräte
+
Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.
{/if}
diff --git a/frontend/src/routes/household/staples/+server.ts b/frontend/src/routes/(app)/household/staples/+server.ts
similarity index 100%
rename from frontend/src/routes/household/staples/+server.ts
rename to frontend/src/routes/(app)/household/staples/+server.ts
diff --git a/frontend/src/routes/household/staples/page.server.test.ts b/frontend/src/routes/(app)/household/staples/page.server.test.ts
similarity index 100%
rename from frontend/src/routes/household/staples/page.server.test.ts
rename to frontend/src/routes/(app)/household/staples/page.server.test.ts
diff --git a/frontend/src/routes/household/staples/page.test.ts b/frontend/src/routes/(app)/household/staples/page.test.ts
similarity index 66%
rename from frontend/src/routes/household/staples/page.test.ts
rename to frontend/src/routes/(app)/household/staples/page.test.ts
index 873bbc9..855954c 100644
--- a/frontend/src/routes/household/staples/page.test.ts
+++ b/frontend/src/routes/(app)/household/staples/page.test.ts
@@ -79,4 +79,36 @@ describe('staples page — settings context (no ctx)', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
});
+
+ it('renders back-link "← Einstellungen" when ctx is null (default settings view)', () => {
+ render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
+ const backLink = screen.getByRole('link', { name: /← einstellungen/i });
+ expect(backLink).toBeInTheDocument();
+ });
+
+ it('back-link points to /settings', () => {
+ render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
+ const backLink = screen.getByRole('link', { name: /← einstellungen/i });
+ expect(backLink).toHaveAttribute('href', '/settings');
+ });
+
+ it('renders hint text about autosave', () => {
+ render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
+ expect(screen.getByText(/änderungen werden automatisch gespeichert/i)).toBeInTheDocument();
+ });
+
+ it('renders hint text about next shopping list', () => {
+ render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
+ expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument();
+ });
+
+ it('does not render back-link in onboarding context', () => {
+ render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
+ expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument();
+ });
+
+ it('does not render hint text in onboarding context', () => {
+ render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
+ expect(screen.queryByText(/änderungen werden automatisch gespeichert/i)).not.toBeInTheDocument();
+ });
});
diff --git a/frontend/src/routes/household/staples/server.test.ts b/frontend/src/routes/(app)/household/staples/server.test.ts
similarity index 100%
rename from frontend/src/routes/household/staples/server.test.ts
rename to frontend/src/routes/(app)/household/staples/server.test.ts
diff --git a/frontend/src/routes/(app)/settings/+page.server.ts b/frontend/src/routes/(app)/settings/+page.server.ts
new file mode 100644
index 0000000..595b303
--- /dev/null
+++ b/frontend/src/routes/(app)/settings/+page.server.ts
@@ -0,0 +1,21 @@
+import type { PageServerLoad } from './$types';
+import { apiClient } from '$lib/server/api';
+
+export const load: PageServerLoad = async ({ fetch, locals }) => {
+ const api = apiClient(fetch);
+
+ const [ingredientsRes, householdRes] = await Promise.all([
+ api.GET('/v1/ingredients'),
+ api.GET('/v1/households/mine')
+ ]);
+
+ const stapleCount = ingredientsRes.data?.filter((i) => i.isStaple).length ?? 0;
+ const memberCount = householdRes.data?.data?.members?.length ?? 0;
+
+ return {
+ stapleCount,
+ memberCount,
+ // hooks.server.ts guarantees benutzer is set for all (app) routes
+ userName: locals.benutzer!.name
+ };
+};
diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte
index f369397..ed4c2e3 100644
--- a/frontend/src/routes/(app)/settings/+page.svelte
+++ b/frontend/src/routes/(app)/settings/+page.svelte
@@ -1 +1,72 @@
-
Einstellungen
+
+
+
diff --git a/frontend/src/routes/(app)/settings/page.server.test.ts b/frontend/src/routes/(app)/settings/page.server.test.ts
new file mode 100644
index 0000000..788cd81
--- /dev/null
+++ b/frontend/src/routes/(app)/settings/page.server.test.ts
@@ -0,0 +1,105 @@
+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 mockIngredients = [
+ { id: 'ing-1', name: 'Olivenöl', isStaple: true },
+ { id: 'ing-2', name: 'Butter', isStaple: false },
+ { id: 'ing-3', name: 'Salz', isStaple: true }
+];
+
+const mockHousehold = {
+ status: 'OK',
+ data: {
+ id: 'hh-1',
+ name: 'Familie Raddatz',
+ members: [
+ { userId: 'u-1', name: 'Marcel' },
+ { userId: 'u-2', name: 'Anna' },
+ { userId: 'u-3', name: 'Ben' }
+ ]
+ }
+};
+
+const mockLocals = { benutzer: { id: 'u-1', name: 'Marcel Raddatz' } };
+
+describe('settings page — load', () => {
+ let load: any;
+
+ beforeEach(async () => {
+ mockGet.mockReset();
+ vi.resetModules();
+ const mod = await import('./+page.server');
+ load = mod.load;
+ });
+
+ function mockApiResponses() {
+ mockGet.mockImplementation((path: string) => {
+ if (path === '/v1/ingredients') {
+ return Promise.resolve({ data: mockIngredients, error: undefined });
+ }
+ if (path === '/v1/households/mine') {
+ return Promise.resolve({ data: mockHousehold, error: undefined });
+ }
+ });
+ }
+
+ it('returns stapleCount as number of ingredients where isStaple=true', async () => {
+ mockApiResponses();
+ const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
+ expect(result.stapleCount).toBe(2);
+ });
+
+ it('returns memberCount as number of household members', async () => {
+ mockApiResponses();
+ const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
+ expect(result.memberCount).toBe(3);
+ });
+
+ it('returns userName from locals.benutzer.name', async () => {
+ mockApiResponses();
+ const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
+ expect(result.userName).toBe('Marcel Raddatz');
+ });
+
+ it('fetches ingredients and household in parallel', async () => {
+ mockApiResponses();
+ await load({ fetch: vi.fn(), locals: mockLocals } as any);
+ const calls = mockGet.mock.calls.map((c) => c[0]);
+ expect(calls).toContain('/v1/ingredients');
+ expect(calls).toContain('/v1/households/mine');
+ });
+
+ it('defaults stapleCount to 0 when ingredients API fails', async () => {
+ mockGet.mockImplementation((path: string) => {
+ if (path === '/v1/ingredients') {
+ return Promise.resolve({ data: undefined, error: { status: 500 } });
+ }
+ if (path === '/v1/households/mine') {
+ return Promise.resolve({ data: mockHousehold, error: undefined });
+ }
+ });
+ const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
+ expect(result.stapleCount).toBe(0);
+ });
+
+ it('defaults memberCount to 0 when household API fails', async () => {
+ mockGet.mockImplementation((path: string) => {
+ if (path === '/v1/ingredients') {
+ return Promise.resolve({ data: mockIngredients, error: undefined });
+ }
+ if (path === '/v1/households/mine') {
+ return Promise.resolve({ data: undefined, error: { status: 500 } });
+ }
+ });
+ const result = await load({ fetch: vi.fn(), locals: mockLocals } as any);
+ expect(result.memberCount).toBe(0);
+ });
+});
diff --git a/frontend/src/routes/(app)/settings/page.test.ts b/frontend/src/routes/(app)/settings/page.test.ts
new file mode 100644
index 0000000..22612e1
--- /dev/null
+++ b/frontend/src/routes/(app)/settings/page.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import Page from './+page.svelte';
+
+function makeData(overrides: Partial<{ stapleCount: number; memberCount: number; userName: string }> = {}) {
+ return {
+ stapleCount: 14,
+ memberCount: 3,
+ userName: 'Marcel Raddatz',
+ ...overrides
+ };
+}
+
+describe('settings page — hub', () => {
+ it('renders the page heading Einstellungen', () => {
+ render(Page, { props: { data: makeData() } });
+ expect(screen.getByRole('heading', { name: /einstellungen/i })).toBeInTheDocument();
+ });
+
+ it('renders Vorräte card linking to /household/staples', () => {
+ render(Page, { props: { data: makeData() } });
+ const links = screen.getAllByRole('link');
+ const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples');
+ expect(vorrateLink).toBeInTheDocument();
+ });
+
+ it('renders Haushalt card linking to /members', () => {
+ render(Page, { props: { data: makeData() } });
+ const links = screen.getAllByRole('link');
+ const haushaltLink = links.find((l) => l.getAttribute('href') === '/members');
+ expect(haushaltLink).toBeInTheDocument();
+ });
+
+ it('renders Profil card linking to /profile', () => {
+ render(Page, { props: { data: makeData() } });
+ const links = screen.getAllByRole('link');
+ const profilLink = links.find((l) => l.getAttribute('href') === '/profile');
+ expect(profilLink).toBeInTheDocument();
+ });
+
+ it('shows stapleCount as a number in the Vorräte card', () => {
+ render(Page, { props: { data: makeData({ stapleCount: 14 }) } });
+ expect(screen.getByTestId('staple-count')).toHaveTextContent('14');
+ });
+
+ it('shows memberCount in the Haushalt card', () => {
+ render(Page, { props: { data: makeData({ memberCount: 3 }) } });
+ expect(screen.getByTestId('member-count')).toHaveTextContent('3');
+ });
+
+ it('shows userName in the Profil card meta', () => {
+ render(Page, { props: { data: makeData({ userName: 'Marcel Raddatz' }) } });
+ expect(screen.getByText('Marcel Raddatz')).toBeInTheDocument();
+ });
+
+ it('shows empty state text when stapleCount is 0', () => {
+ render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
+ expect(screen.getByText(/noch keine vorräte/i)).toBeInTheDocument();
+ expect(screen.queryByTestId('staple-count')).not.toBeInTheDocument();
+ });
+
+ it('shows "Jetzt einrichten →" CTA when stapleCount is 0', () => {
+ render(Page, { props: { data: makeData({ stapleCount: 0 }) } });
+ expect(screen.getByText('Jetzt einrichten →')).toBeInTheDocument();
+ });
+
+ it('shows "Vorräte bearbeiten →" CTA when stapleCount > 0', () => {
+ render(Page, { props: { data: makeData({ stapleCount: 5 }) } });
+ expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument();
+ });
+});