Compare commits
6 Commits
1dd6e054fc
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c2c83996 | ||
|
|
c317c085aa | ||
|
|
bc805cb178 | ||
|
|
ce41e96a45 | ||
|
|
a6c8af0971 | ||
|
|
6d9910b805 |
@@ -80,6 +80,34 @@ public class UserService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AppUser createUserForBootstrap(CreateUserRequest request) {
|
||||||
|
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
|
||||||
|
|
||||||
|
Set<UserGroup> groups = new HashSet<>();
|
||||||
|
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||||
|
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||||
|
if (existingUser.isPresent()) {
|
||||||
|
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||||
|
return userRepository.save(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppUser user = AppUser.builder()
|
||||||
|
.email(request.getEmail())
|
||||||
|
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||||
|
.groups(groups)
|
||||||
|
.firstName(request.getFirstName())
|
||||||
|
.lastName(request.getLastName())
|
||||||
|
.birthDate(request.getBirthDate())
|
||||||
|
.contact(request.getContact())
|
||||||
|
.enabled(true)
|
||||||
|
.build();
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser createUser(String email, String rawPassword, String firstName, String lastName, Set<UUID> groupIds) {
|
public AppUser createUser(String email, String rawPassword, String firstName, String lastName, Set<UUID> groupIds) {
|
||||||
userRepository.findByEmail(email).ifPresent(existing -> {
|
userRepository.findByEmail(email).ifPresent(existing -> {
|
||||||
|
|||||||
@@ -45,18 +45,13 @@ class UserManagementAuditIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndDeleteUser_producesOrderedAuditEntries() {
|
void createAndDeleteUser_producesOrderedAuditEntries() {
|
||||||
// Create the actor (admin) user directly — bypasses audit logging so no FK issue
|
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
|
||||||
CreateUserRequest adminReq = new CreateUserRequest();
|
CreateUserRequest adminReq = new CreateUserRequest();
|
||||||
adminReq.setEmail("admin@test.example.com");
|
adminReq.setEmail("admin@test.example.com");
|
||||||
adminReq.setInitialPassword("admin-secret");
|
adminReq.setInitialPassword("admin-secret");
|
||||||
AppUser actor = transactionTemplate.execute(status ->
|
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
|
||||||
userService.createUserOrUpdate(null, adminReq));
|
|
||||||
UUID actorId = actor.getId();
|
UUID actorId = actor.getId();
|
||||||
|
|
||||||
// The admin creation is logged with null actorId — clear to start with a clean slate
|
|
||||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
|
||||||
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
|
||||||
|
|
||||||
// Create the target user — should emit USER_CREATED
|
// Create the target user — should emit USER_CREATED
|
||||||
CreateUserRequest req = new CreateUserRequest();
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
req.setEmail("audit-test@example.com");
|
req.setEmail("audit-test@example.com");
|
||||||
@@ -65,7 +60,7 @@ class UserManagementAuditIntegrationTest {
|
|||||||
userService.createUserOrUpdate(actorId, req);
|
userService.createUserOrUpdate(actorId, req);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
||||||
|
|
||||||
// Delete the target user — should emit USER_DELETED
|
// Delete the target user — should emit USER_DELETED
|
||||||
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
|
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
|
||||||
@@ -73,7 +68,7 @@ class UserManagementAuditIntegrationTest {
|
|||||||
userService.deleteUser(actorId, created.getId());
|
userService.deleteUser(actorId, created.getId());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
|
||||||
|
|
||||||
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
||||||
assertThat(events).hasSize(2);
|
assertThat(events).hasSize(2);
|
||||||
@@ -83,27 +78,24 @@ class UserManagementAuditIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateUserGroups_producesGroupMembershipChangedEvent() {
|
void updateUserGroups_producesGroupMembershipChangedEvent() {
|
||||||
// Create groups before creating users — required for group assignment on creation
|
|
||||||
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
|
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
|
||||||
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
|
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
|
||||||
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
|
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
|
||||||
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
|
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
|
||||||
|
|
||||||
// Create actor (bootstrap — null actorId, event not relevant)
|
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
|
||||||
CreateUserRequest actorReq = new CreateUserRequest();
|
CreateUserRequest actorReq = new CreateUserRequest();
|
||||||
actorReq.setEmail("actor-group-test@test.example.com");
|
actorReq.setEmail("actor-group-test@test.example.com");
|
||||||
actorReq.setInitialPassword("secret");
|
actorReq.setInitialPassword("secret");
|
||||||
AppUser actor = transactionTemplate.execute(status -> userService.createUserOrUpdate(null, actorReq));
|
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
|
||||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
|
||||||
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
|
||||||
|
|
||||||
// Create target user pre-assigned to gA
|
// Create target user pre-assigned to gA — emits USER_CREATED
|
||||||
CreateUserRequest targetReq = new CreateUserRequest();
|
CreateUserRequest targetReq = new CreateUserRequest();
|
||||||
targetReq.setEmail("target-group-test@test.example.com");
|
targetReq.setEmail("target-group-test@test.example.com");
|
||||||
targetReq.setInitialPassword("secret");
|
targetReq.setInitialPassword("secret");
|
||||||
targetReq.setGroupIds(List.of(gA.getId()));
|
targetReq.setGroupIds(List.of(gA.getId()));
|
||||||
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
|
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
|
||||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
||||||
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
||||||
|
|
||||||
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
|
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
|
||||||
@@ -113,7 +105,7 @@ class UserManagementAuditIntegrationTest {
|
|||||||
dto.setGroupIds(List.of(gB.getId()));
|
dto.setGroupIds(List.of(gB.getId()));
|
||||||
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
|
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
|
||||||
|
|
||||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
|
||||||
|
|
||||||
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
||||||
assertThat(events).hasSize(1);
|
assertThat(events).hasSize(1);
|
||||||
|
|||||||
@@ -133,4 +133,28 @@ class UserControllerTest {
|
|||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── unauthenticated access ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users")
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -837,6 +837,26 @@ class UserServiceTest {
|
|||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── createUserForBootstrap ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserForBootstrap_createsUserWithoutAuditEvent() {
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("bootstrap@example.com");
|
||||||
|
req.setInitialPassword("secret");
|
||||||
|
req.setGroupIds(List.of());
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||||
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
|
||||||
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
AppUser result = userService.createUserForBootstrap(req);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(saved);
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── createGroup ──────────────────────────────────────────────────────────
|
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Briefwechsel",
|
"nav_conversations": "Briefwechsel",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Abmelden",
|
"nav_logout": "Abmelden",
|
||||||
|
"theme_toggle_to_light": "Zu hellem Design wechseln",
|
||||||
|
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
"btn_cancel": "Abbrechen",
|
"btn_cancel": "Abbrechen",
|
||||||
"btn_confirm": "Bestätigen",
|
"btn_confirm": "Bestätigen",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Letters",
|
"nav_conversations": "Letters",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Sign out",
|
"nav_logout": "Sign out",
|
||||||
|
"theme_toggle_to_light": "Switch to light mode",
|
||||||
|
"theme_toggle_to_dark": "Switch to dark mode",
|
||||||
"btn_save": "Save",
|
"btn_save": "Save",
|
||||||
"btn_cancel": "Cancel",
|
"btn_cancel": "Cancel",
|
||||||
"btn_confirm": "Confirm",
|
"btn_confirm": "Confirm",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Cartas",
|
"nav_conversations": "Cartas",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Cerrar sesión",
|
"nav_logout": "Cerrar sesión",
|
||||||
|
"theme_toggle_to_light": "Cambiar a modo claro",
|
||||||
|
"theme_toggle_to_dark": "Cambiar a modo oscuro",
|
||||||
"btn_save": "Guardar",
|
"btn_save": "Guardar",
|
||||||
"btn_cancel": "Cancelar",
|
"btn_cancel": "Cancelar",
|
||||||
"btn_confirm": "Confirmar",
|
"btn_confirm": "Confirmar",
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bellLabel = $derived(
|
||||||
|
stream.unreadCount > 0
|
||||||
|
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||||
|
: m.notification_bell_label()
|
||||||
|
);
|
||||||
|
|
||||||
function attachBellButton(node: HTMLButtonElement) {
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
bellButtonEl = node;
|
bellButtonEl = node;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,12 +78,11 @@ onDestroy(() => {
|
|||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={stream.unreadCount > 0
|
aria-label={bellLabel}
|
||||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
title={bellLabel}
|
||||||
: m.notification_bell_label()}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
|
|||||||
notifButton.click();
|
notifButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('NotificationBell — cursor and tooltip', () => {
|
||||||
|
it('bell button has cursor-pointer class', async () => {
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
||||||
|
mockNotificationList.value = [];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
||||||
|
mockNotificationList.value = [
|
||||||
|
makeNotification({ id: 'n1' }),
|
||||||
|
makeNotification({ id: 'n2' }),
|
||||||
|
makeNotification({ id: 'n3' })
|
||||||
|
];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
describe('NotificationBell', () => {
|
||||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ onMount(() => {
|
|||||||
theme = resolveInitialTheme();
|
theme = resolveInitialTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const themeLabel = $derived(
|
||||||
|
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
|
||||||
|
);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
@@ -29,8 +34,8 @@ function toggle() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
aria-label={themeLabel}
|
||||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
title={themeLabel}
|
||||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{#if theme === 'dark'}
|
{#if theme === 'dark'}
|
||||||
|
|||||||
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import ThemeToggle from './ThemeToggle.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (light mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to dark mode when theme is light', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in light mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (dark mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to light mode when theme is dark', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in dark mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -365,6 +365,11 @@
|
|||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--c-focus-ring);
|
outline: 2px solid var(--c-focus-ring);
|
||||||
|
|||||||
Reference in New Issue
Block a user