Compare commits
12 Commits
9626bde694
...
682580e11d
| Author | SHA1 | Date | |
|---|---|---|---|
| 682580e11d | |||
| 5c066d33ef | |||
| 4bd020fa16 | |||
| bd8e901685 | |||
| aeaca76534 | |||
| 32550377aa | |||
| 92c7d8f92e | |||
| cc74c0042a | |||
| 2bdb1010f8 | |||
| d7f317587e | |||
| 05bf66de56 | |||
| db4b01ca77 |
2
frontend/src/app.d.ts
vendored
2
frontend/src/app.d.ts
vendored
@@ -12,7 +12,7 @@ declare global {
|
||||
rolle: 'planer' | 'mitglied';
|
||||
};
|
||||
haushalt?: {
|
||||
id: string;
|
||||
id: string | undefined;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') })
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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') })
|
||||
};
|
||||
|
||||
37
frontend/src/lib/nav/MobileTabBar.routes.test.ts
Normal file
37
frontend/src/lib/nav/MobileTabBar.routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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') })
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '⚙️' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user