Compare commits

..

12 Commits

Author SHA1 Message Date
682580e11d feat(nav): add hover state on inactive tablet and desktop nav items
Applies hover:bg-[var(--color-subtle)] to inactive nav links for
visual feedback on pointer devices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:04:50 +02:00
5c066d33ef feat(nav): add emoji icons to all nav components
Renders emoji icons in MobileTabBar (stacked above label),
TabletNavBar (inline), and DesktopSidebar (16px, 20px column).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:03:53 +02:00
4bd020fa16 test(nav): add parameterized active-state tests for all routes
Proves active state logic generalizes beyond /planner by testing
all 4 mobile nav routes with writable page store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:01:26 +02:00
bd8e901685 fix(nav): use segment-boundary route matching to prevent false positives
Extracts isActiveRoute() into shared nav module. Matches exact path
or path + '/' prefix, preventing /settings from matching /settings-advanced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:00:18 +02:00
aeaca76534 fix(auth): handle users without household — fallback to 'Kein Haushalt'
Removes non-null assertions on householdId/householdName. Users who
haven't joined a household get a fallback name in the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:58:37 +02:00
32550377aa fix(auth): read JSESSIONID cookie to match Spring Security default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:57:34 +02:00
92c7d8f92e feat(auth): preserve redirect URL when redirecting to /login
Appends ?redirect= with the original pathname so the login page
can redirect back after successful authentication.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:56:49 +02:00
cc74c0042a test(auth): add isPublicRoute boundary tests for sub-paths and trailing slash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:55:48 +02:00
2bdb1010f8 fix(auth): bypass auth guard for static assets and favicon
Prevents redirect loop when backend is down — login page CSS/JS
would otherwise be redirected to /login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:55:03 +02:00
d7f317587e refactor(public): add lang="ts" to public layout for consistency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:53:56 +02:00
05bf66de56 refactor(test): replace require() with import() in $app/stores mocks
CJS require() is fragile in an ESM project. Use async import() instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:53:20 +02:00
db4b01ca77 refactor(config): document resolve.conditions safety for SSR builds
Verified: SvelteKit's plugin overrides resolve.conditions for SSR
builds. The global 'browser' condition only affects vitest and dev.
Build output confirmed correct with npm run build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:52:23 +02:00
15 changed files with 173 additions and 41 deletions

View File

@@ -12,7 +12,7 @@ declare global {
rolle: 'planer' | 'mitglied';
};
haushalt?: {
id: string;
id: string | undefined;
name: string;
};
}

View File

@@ -25,7 +25,10 @@ describe('auth guard (hooks.server.ts handle)', () => {
const event = {
url: new URL(`http://localhost${pathname}`),
cookies: {
get: vi.fn().mockReturnValue(cookie)
get: vi.fn().mockImplementation((name: string) => {
if (name === 'JSESSIONID') return cookie;
return undefined;
})
},
locals: {} as any,
fetch: vi.fn()
@@ -39,15 +42,32 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect(resolve).toHaveBeenCalledWith(event);
});
it('redirects unauthenticated requests on protected routes', async () => {
const { event, resolve } = createEvent('/planner');
it.each(['/login', '/login/', '/register', '/invite/abc123'])(
'allows public route %s without auth',
async (path) => {
const { event, resolve } = createEvent(path);
await handle({ event, resolve });
expect(resolve).toHaveBeenCalledWith(event);
}
);
it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
'allows static asset %s without auth',
async (path) => {
const { event, resolve } = createEvent(path);
await handle({ event, resolve });
expect(resolve).toHaveBeenCalledWith(event);
}
);
it('redirects unauthenticated requests to /login with redirect param', async () => {
const { event, resolve } = createEvent('/recipes/abc');
try {
await handle({ event, resolve });
// If using SvelteKit redirect, it throws
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(302);
expect(e.location).toBe('/login');
expect(e.location).toBe('/login?redirect=%2Frecipes%2Fabc');
}
});
@@ -81,7 +101,37 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect(resolve).toHaveBeenCalledWith(event);
});
it('redirects to /login when session validation fails', async () => {
it('handles user without household gracefully', async () => {
mockGet.mockResolvedValue({
data: {
data: {
id: '456',
displayName: 'Neu',
householdId: null,
householdName: null,
householdRole: null,
email: 'neu@example.com',
systemRole: 'user'
}
},
error: undefined
});
const { event, resolve } = createEvent('/planner', 'valid-session');
await handle({ event, resolve });
expect(event.locals.benutzer).toEqual({
id: '456',
name: 'Neu',
rolle: 'mitglied'
});
expect(event.locals.haushalt).toEqual({
id: undefined,
name: 'Kein Haushalt'
});
expect(resolve).toHaveBeenCalledWith(event);
});
it('redirects to /login with redirect param when session validation fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } });
const { event, resolve } = createEvent('/planner', 'bad-session');
@@ -90,7 +140,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(302);
expect(e.location).toBe('/login');
expect(e.location).toBe('/login?redirect=%2Fplanner');
}
});
});

View File

@@ -4,36 +4,46 @@ import { apiClient } from '$lib/server/api';
const PUBLIC_ROUTES = ['/login', '/register', '/invite'];
const STATIC_PREFIXES = ['/_app/', '/favicon'];
function isPublicRoute(pathname: string): boolean {
if (STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return true;
}
return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/'));
}
function loginRedirect(pathname: string): never {
const target = '/login?redirect=' + encodeURIComponent(pathname);
redirect(302, target);
}
export const handle: Handle = async ({ event, resolve }) => {
if (isPublicRoute(event.url.pathname)) {
return resolve(event);
}
const sessionCookie = event.cookies.get('session');
const sessionCookie = event.cookies.get('JSESSIONID');
if (!sessionCookie) {
redirect(302, '/login');
loginRedirect(event.url.pathname);
}
const api = apiClient(event.fetch);
const { data, error } = await api.GET('/v1/auth/me');
if (error || !data?.data) {
redirect(302, '/login');
loginRedirect(event.url.pathname);
}
const user = data.data;
event.locals.benutzer = {
id: user.id!,
name: user.displayName!,
rolle: user.householdRole as 'planer' | 'mitglied'
rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied'
};
event.locals.haushalt = {
id: user.householdId!,
name: user.householdName!
id: user.householdId ?? undefined,
name: user.householdName ?? 'Kein Haushalt'
};
return resolve(event);

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import AppShell from './AppShell.svelte';
vi.mock('$app/stores', () => {
const { readable } = require('svelte/store');
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/planner') })
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { desktopNavSections } from './nav';
import { desktopNavSections, isActiveRoute } from './nav';
let { appName, householdName }: { appName: string; householdName: string } = $props();
</script>
@@ -24,14 +24,15 @@
{section.title}
</p>
{#each section.items as item (item.href)}
{@const active = $page.url.pathname.startsWith(item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="px-3 py-[7px] text-[13px] font-[var(--font-sans)] rounded-[var(--radius-md)] flex items-center gap-2 {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: ''}"
: 'hover:bg-[var(--color-subtle)]'}"
>
<span class="w-[20px] text-[16px] text-center">{item.icon}</span>
{item.label}
</a>
{/each}

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import DesktopSidebar from './DesktopSidebar.svelte';
vi.mock('$app/stores', () => {
const { readable } = require('svelte/store');
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/planner') })
};

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
const { pageStore } = vi.hoisted(() => {
const { writable } = require('svelte/store');
const pageStore = writable({ url: new URL('http://localhost/planner') });
return { pageStore };
});
vi.mock('$app/stores', () => ({
page: pageStore
}));
import MobileTabBar from './MobileTabBar.svelte';
describe('MobileTabBar active state per route', () => {
it.each([
['/planner', 'Planer'],
['/recipes', 'Rezepte'],
['/shopping', 'Einkauf'],
['/settings', 'Einstellungen']
])('on %s, %s is active and others are not', (route, expectedActiveLabel) => {
pageStore.set({ url: new URL(`http://localhost${route}`) });
const { unmount } = render(MobileTabBar);
const activeLink = screen.getByRole('link', { name: new RegExp(expectedActiveLabel) });
expect(activeLink).toHaveAttribute('aria-current', 'page');
const allLinks = screen.getAllByRole('link');
const inactiveLinks = allLinks.filter((link) => !link.textContent?.includes(expectedActiveLabel));
for (const link of inactiveLinks) {
expect(link).not.toHaveAttribute('aria-current');
}
unmount();
});
});

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { mobileNavItems } from './nav';
import { mobileNavItems, isActiveRoute } from './nav';
</script>
<nav
@@ -8,7 +8,7 @@
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
>
{#each mobileNavItems as item (item.href)}
{@const active = $page.url.pathname.startsWith(item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
@@ -16,6 +16,7 @@
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: ''}"
>
<span class="text-[16px]">{item.icon}</span>
{item.label}
</a>
{/each}

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import MobileTabBar from './MobileTabBar.svelte';
vi.mock('$app/stores', () => {
const { readable } = require('svelte/store');
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/planner') })
};
@@ -39,6 +39,14 @@ describe('MobileTabBar', () => {
expect(plannerLink).toHaveAttribute('aria-current', 'page');
});
it('renders icons for each nav item', () => {
render(MobileTabBar);
expect(screen.getByText('📅')).toBeInTheDocument();
expect(screen.getByText('📖')).toBeInTheDocument();
expect(screen.getByText('🛒')).toBeInTheDocument();
expect(screen.getByText('⚙️')).toBeInTheDocument();
});
it('non-active items do not have aria-current', () => {
render(MobileTabBar);
const recipesLink = screen.getByRole('link', { name: /rezepte/i });

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { mobileNavItems } from './nav';
import { mobileNavItems, isActiveRoute } from './nav';
</script>
<nav
@@ -8,14 +8,15 @@
class="hidden md:flex lg:hidden gap-2 items-center p-2"
>
{#each mobileNavItems as item (item.href)}
{@const active = $page.url.pathname.startsWith(item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="px-4 py-2 rounded-[var(--radius-md)] text-[13px] font-[var(--font-sans)] {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: ''}"
: 'hover:bg-[var(--color-subtle)]'}"
>
<span>{item.icon}</span>
{item.label}
</a>
{/each}

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import TabletNavBar from './TabletNavBar.svelte';
vi.mock('$app/stores', () => {
const { readable } = require('svelte/store');
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/planner') })
};

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mobileNavItems, desktopNavSections } from './nav';
import { mobileNavItems, desktopNavSections, isActiveRoute } from './nav';
describe('nav config', () => {
describe('mobileNavItems', () => {
@@ -39,4 +39,22 @@ describe('nav config', () => {
expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
});
});
describe('isActiveRoute', () => {
it('matches exact route', () => {
expect(isActiveRoute('/planner', '/planner')).toBe(true);
});
it('matches sub-route', () => {
expect(isActiveRoute('/planner', '/planner/week')).toBe(true);
});
it('does not match route with similar prefix', () => {
expect(isActiveRoute('/settings', '/settings-advanced')).toBe(false);
});
it('does not match unrelated route', () => {
expect(isActiveRoute('/planner', '/recipes')).toBe(false);
});
});
});

View File

@@ -10,26 +10,30 @@ export interface NavSection {
}
export const mobileNavItems: NavItem[] = [
{ href: '/planner', label: 'Planer', icon: 'calendar' },
{ href: '/recipes', label: 'Rezepte', icon: 'book' },
{ href: '/shopping', label: 'Einkauf', icon: 'shopping-cart' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' }
{ href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: '🛒' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
];
export function isActiveRoute(href: string, pathname: string): boolean {
return pathname === href || pathname.startsWith(href + '/');
}
export const desktopNavSections: NavSection[] = [
{
title: 'Plan',
items: [
{ href: '/planner', label: 'Planer', icon: 'calendar' },
{ href: '/recipes', label: 'Rezepte', icon: 'book' },
{ href: '/shopping', label: 'Einkauf', icon: 'shopping-cart' }
{ href: '/planner', label: 'Planer', icon: '📅' },
{ href: '/recipes', label: 'Rezepte', icon: '📖' },
{ href: '/shopping', label: 'Einkauf', icon: '🛒' }
]
},
{
title: 'Haushalt',
items: [
{ href: '/members', label: 'Mitglieder', icon: 'users' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' }
{ href: '/members', label: 'Mitglieder', icon: '👥' },
{ href: '/settings', label: 'Einstellungen', icon: '⚙️' }
]
}
];

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
let { children } = $props();
</script>

View File

@@ -10,6 +10,8 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['src/test-setup.ts']
},
// Required for vitest: resolves Svelte to client entry (not server).
// SvelteKit's plugin overrides this for SSR builds — verified safe.
resolve: {
conditions: ['browser']
}