Files
familienarchiv/frontend/src/routes/layout.svelte.spec.ts
Marcel 97274beba0 test(layout): lock upload-button gate against ANNOTATE_ALL-only users (#696)
Documents that the gate keys on lack of WRITE_ALL, not on being READ_ALL:
an ANNOTATE_ALL-only user (canWrite=false) must still not see the upload
link. The writer-sees-it contract is already covered by the existing
upload-link tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:08:51 +02:00

144 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { createRawSnippet } from 'svelte';
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
// NotificationBell calls notificationStore.init() on mount, which creates an
// EventSource and immediately fetches the unread count. In the test environment
// the SSE connection fails (no backend), triggering onerror → another fetch.
// If that fetch returns 401, notifications.svelte.ts calls
// window.location.href = '/login', navigating the test iframe and breaking the
// Playwright connection. Stubbing fetch to return 200 keeps it in-check.
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ count: 0, content: [] })
})
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const emptySnippet = createRawSnippet(() => ({ render: () => '<span></span>' }));
import Layout from './+layout.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
// Minimal data required by the layout
const makeData = (overrides = {}) => ({
user: {
id: '1',
firstName: 'Max',
lastName: 'Müller',
email: 'max@example.com',
groups: [],
enabled: true,
createdAt: ''
},
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
...overrides
});
// ─── User avatar ──────────────────────────────────────────────────────────────
describe('Layout user avatar button', () => {
it('shows user initials when first and last name are set', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await expect.element(page.getByRole('button', { name: /MM/ })).toBeInTheDocument();
});
it('shows fallback icon button when names are not set', async () => {
render(Layout, {
data: makeData({
user: { id: '1', email: 'fallback@example.com', groups: [], enabled: true, createdAt: '' }
}),
children: emptySnippet
});
// Button should still exist (with aria-label for accessibility)
await expect.element(page.getByRole('button', { name: /Profil/i })).toBeInTheDocument();
});
});
// ─── Upload link ──────────────────────────────────────────────────────────────
describe('Layout upload link', () => {
it('has aria-label for screen reader access', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i });
await expect.element(link).toHaveAttribute('aria-label');
});
it('navigates to /documents/new', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i });
await expect.element(link).toHaveAttribute('href', '/documents/new');
});
it('is hidden for a user without WRITE_ALL', async () => {
render(Layout, { data: makeData({ canWrite: false }), children: emptySnippet });
await expect
.element(page.getByRole('link', { name: /Hochladen|Upload|Subir/i }))
.not.toBeInTheDocument();
});
it('is hidden for an ANNOTATE_ALL-only user (gate is lack of WRITE_ALL, not READ_ALL)', async () => {
render(Layout, {
data: makeData({ canWrite: false, canAnnotate: true }),
children: emptySnippet
});
await expect
.element(page.getByRole('link', { name: /Hochladen|Upload|Subir/i }))
.not.toBeInTheDocument();
});
});
// ─── Dropdown ─────────────────────────────────────────────────────────────────
describe('Layout user dropdown', () => {
it('dropdown is hidden initially', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
await tick();
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
});
it('opens dropdown on button click', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
});
it('profile link points to /profile', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
await expect
.element(page.getByRole('link', { name: /Profil/i }))
.toHaveAttribute('href', '/profile');
});
it('logout button is in the dropdown', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
((await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement).click();
await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument();
});
it('closes dropdown when Escape is pressed', async () => {
render(Layout, { data: makeData(), children: emptySnippet });
const btnEl = (await page.getByRole('button', { name: /MM/ }).element()) as HTMLElement;
btnEl.click();
await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument();
btnEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await tick();
await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument();
});
});