dev #59

Merged
torsten merged 5 commits from dev into main 2026-10-29 22:24:53 +01:00
9 changed files with 325 additions and 51 deletions
Showing only changes of commit 4ecc2cc90c - Show all commits

View File

@@ -88,13 +88,12 @@ class NotificationPreferencesRepository @Inject constructor(
} }
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> { suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
saveLocal(settings)
return runCatching { return runCatching {
retryOnNetworkFailure { retryOnNetworkFailure {
val response = api.updateNotificationSettings(settings.toDto()) val response = api.updateNotificationSettings(settings.toDto())
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.") if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort") val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
saveLocal(saved) requireLiveNotificationSettingsSaved(settings, saved)
saved saved
} }
} }
@@ -120,6 +119,15 @@ class NotificationPreferencesRepository @Inject constructor(
} }
} }
internal fun requireLiveNotificationSettingsSaved(requested: NotificationPreferences, saved: NotificationPreferences) {
if (requested.liveTickerStarts != saved.liveTickerStarts ||
requested.liveTickerScoreChanges != saved.liveTickerScoreChanges ||
requested.liveTickerFinished != saved.liveTickerFinished
) {
error("Der Server hat die Live-Benachrichtigungen nicht übernommen. Bitte das Backend aktualisieren und erneut versuchen.")
}
}
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences( private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
newNews = newNews, newNews = newNews,
newEvents = newEvents, newEvents = newEvents,

View File

@@ -1,25 +1,40 @@
package de.harheimertc.ui.components package de.harheimertc.ui.components
import androidx.compose.foundation.horizontalScroll import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.harheimertc.data.LiveTickerMatchDto import de.harheimertc.data.LiveTickerMatchDto
import kotlinx.coroutines.isActive
@Composable @Composable
fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) { fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
@@ -45,34 +60,78 @@ fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
if (matches.isEmpty()) return if (matches.isEmpty()) return
var paused by remember { mutableStateOf(false) }
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) { Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
Row( Row(verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier TextButton(
.horizontalScroll(rememberScrollState()) onClick = { paused = !paused },
.padding(horizontal = 16.dp, vertical = 9.dp), modifier = Modifier.semantics {
) { contentDescription = if (paused) "Laufschrift fortsetzen" else "Laufschrift anhalten"
matches.forEachIndexed { index, match -> },
if (index > 0) { ) {
Text(" • ", color = Color(0xFF64748B)) Text(if (paused) "▶" else "Ⅱ", color = Color(0xFF334155))
}
BoxWithConstraints(modifier = Modifier.weight(1f).clipToBounds()) {
val viewportWidth = maxWidth
var groupWidth by remember { mutableStateOf(0) }
val offset = remember { Animatable(0f) }
val pixelsPerSecond = with(LocalDensity.current) { 36.dp.toPx() }
LaunchedEffect(groupWidth, pixelsPerSecond, paused) {
if (groupWidth <= 0 || paused) return@LaunchedEffect
offset.snapTo(offset.value % groupWidth)
while (isActive) {
val duration = ((groupWidth - offset.value) / pixelsPerSecond * 1000).toInt().coerceAtLeast(1)
offset.animateTo(groupWidth.toFloat(), tween(duration, easing = LinearEasing))
offset.snapTo(0f)
// Also yield when system animations are disabled.
withFrameNanos { }
}
} }
val status = when { Row(
match.completed -> "✓ beendet" modifier = Modifier
match.hasLiveData -> "● LIVE" .wrapContentWidth(Alignment.Start, unbounded = true)
else -> "◌ Keine Live-Daten" .graphicsLayer { translationX = -offset.value },
) {
repeat(2) { copy ->
LiveTickerGroup(
matches = matches,
modifier = Modifier
.widthIn(min = viewportWidth)
.then(if (copy == 0) Modifier.onSizeChanged { groupWidth = it.width } else Modifier.clearAndSetSemantics {}),
)
}
} }
Text(
text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}",
color = when {
match.completed -> Color(0xFF64748B)
match.hasLiveData -> Color(0xFFB91C1C)
else -> Color(0xFF334155)
},
style = MaterialTheme.typography.labelLarge,
fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold,
fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal,
maxLines = 1,
)
} }
} }
} }
} }
@Composable
private fun LiveTickerGroup(matches: List<LiveTickerMatchDto>, modifier: Modifier = Modifier) {
Row(
modifier = modifier.padding(start = 16.dp, end = 32.dp, top = 9.dp, bottom = 9.dp),
) {
matches.forEachIndexed { index, match ->
if (index > 0) {
Text(" • ", color = Color(0xFF64748B))
}
val status = when {
match.completed -> "✓ beendet"
match.hasLiveData -> "● LIVE"
else -> "◌ Keine Live-Daten"
}
Text(
text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}",
color = when {
match.completed -> Color(0xFF64748B)
match.hasLiveData -> Color(0xFFB91C1C)
else -> Color(0xFF334155)
},
style = MaterialTheme.typography.labelLarge,
fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold,
fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal,
maxLines = 1,
)
}
}
}

View File

@@ -210,7 +210,10 @@ fun NotificationSettingsScreen(
} }
state.saveError?.let { message -> state.saveError?.let { message ->
item { Text(message, color = MaterialTheme.colorScheme.error) } item {
Text(message, color = MaterialTheme.colorScheme.error)
TextButton(onClick = { viewModel.update(state.settings) }) { Text("Erneut speichern") }
}
} }
state.error?.let { message -> state.error?.let { message ->

View File

@@ -12,6 +12,8 @@ import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject import javax.inject.Inject
data class NotificationSettingsUiState( data class NotificationSettingsUiState(
@@ -37,6 +39,8 @@ class NotificationSettingsViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NotificationSettingsUiState()) private val _state = MutableStateFlow(NotificationSettingsUiState())
val state: StateFlow<NotificationSettingsUiState> = _state val state: StateFlow<NotificationSettingsUiState> = _state
private val saveMutex = Mutex()
private var settingsRevision = 0L
init { init {
load() load()
@@ -66,14 +70,21 @@ class NotificationSettingsViewModel @Inject constructor(
} }
fun update(settings: NotificationPreferences) { fun update(settings: NotificationPreferences) {
val revision = ++settingsRevision
preferencesRepository.saveLocal(settings) preferencesRepository.saveLocal(settings)
_state.value = _state.value.copy(settings = settings, saveError = null) _state.value = _state.value.copy(settings = settings, saveError = null)
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.saveRemote(settings) saveMutex.withLock {
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) } if (revision != settingsRevision) return@withLock
.onFailure { error -> val result = preferencesRepository.saveRemote(settings)
if (revision != settingsRevision) return@withLock
result.onSuccess { saved ->
preferencesRepository.saveLocal(saved)
_state.value = _state.value.copy(settings = saved, saveError = null)
}.onFailure { error ->
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.") _state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
} }
}
} }
} }

View File

@@ -38,7 +38,7 @@ class LoginViewModelTest {
val repo = mockk<de.harheimertc.repositories.LoginRepository>() val repo = mockk<de.harheimertc.repositories.LoginRepository>()
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>() val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse()) coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
val vm = LoginViewModel(repo, passkeyRepo) val vm = LoginViewModel(repo, passkeyRepo, mockk(relaxed = true))
vm.setEmail("invalid-email") vm.setEmail("invalid-email")
vm.setPassword("") vm.setPassword("")
@@ -57,7 +57,7 @@ class LoginViewModelTest {
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>() val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
coEvery { repo.login(any(), any()) } returns Result.success(loginResp) coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse()) coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
val vm = LoginViewModel(repo, passkeyRepo) val vm = LoginViewModel(repo, passkeyRepo, mockk(relaxed = true))
vm.setEmail("max@ex.de") vm.setEmail("max@ex.de")
vm.setPassword("secret") vm.setPassword("secret")

View File

@@ -0,0 +1,96 @@
package de.harheimertc.ui.screens.notifications
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.repositories.NotificationPreferencesRepository
import de.harheimertc.repositories.PushTokenRepository
import de.harheimertc.repositories.requireLiveNotificationSettingsSaved
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationSettingsViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val preferences = mockk<NotificationPreferencesRepository>(relaxed = true)
private val teams = mockk<MannschaftenRepository>()
private val login = mockk<LoginRepository>()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
every { preferences.loadLocal() } returns NotificationPreferences()
coEvery { preferences.loadRemote() } returns Result.success(NotificationPreferences())
coEvery { login.status() } returns Result.success(AuthStatusResponse())
coEvery { teams.fetchSeasons() } returns Result.failure(IllegalStateException("offline"))
coEvery { teams.fetchMannschaften(any()) } returns Result.success(emptyList())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel() = NotificationSettingsViewModel(preferences, teams, login, mockk<PushTokenRepository>())
@Test
fun olderResponseCannotUndoNewerSwitches() = runTest(dispatcher) {
val firstResponse = CompletableDeferred<Result<NotificationPreferences>>()
val first = NotificationPreferences(liveTickerStarts = true)
val latest = first.copy(liveTickerScoreChanges = true, liveTickerFinished = true)
coEvery { preferences.saveRemote(first) } coAnswers { firstResponse.await() }
coEvery { preferences.saveRemote(latest) } returns Result.success(latest)
val vm = viewModel()
runCurrent()
vm.update(first)
runCurrent()
vm.update(latest)
runCurrent()
firstResponse.complete(Result.success(first))
runCurrent()
assertEquals(latest, vm.state.value.settings)
verify(exactly = 1) { preferences.saveLocal(first) }
verify(atLeast = 1) { preferences.saveLocal(latest) }
}
@Test
fun rejectedLiveSettingsStayVisibleWithSaveError() = runTest(dispatcher) {
val requested = NotificationPreferences(liveTickerFinished = true)
coEvery { preferences.saveRemote(requested) } returns Result.failure(IllegalStateException("Backend aktualisieren"))
val vm = viewModel()
runCurrent()
vm.update(requested)
runCurrent()
assertTrue(vm.state.value.settings.liveTickerFinished)
assertEquals("Backend aktualisieren", vm.state.value.saveError)
}
@Test
fun serverMustConfirmEachLivePreference() {
val defaults = NotificationPreferences()
for (requested in listOf(
defaults.copy(liveTickerStarts = true),
defaults.copy(liveTickerScoreChanges = true),
defaults.copy(liveTickerFinished = true),
)) {
val error = runCatching { requireLiveNotificationSettingsSaved(requested, defaults) }.exceptionOrNull()
assertTrue(error?.message.orEmpty().contains("Server"))
requireLiveNotificationSettingsSaved(requested, requested)
}
}
}

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=38 ANDROID_VERSION_CODE=39
ANDROID_VERSION_NAME=0.11.0 ANDROID_VERSION_NAME=0.11.1
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

View File

@@ -3,7 +3,7 @@
v-if="isInternal && matches.length" v-if="isInternal && matches.length"
class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm" class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
> >
<div class="mx-auto flex max-w-7xl items-center gap-3 overflow-x-auto px-4 py-2 text-sm sm:px-6"> <div class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-2 text-sm sm:px-6">
<button <button
type="button" type="button"
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500" class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
@@ -12,21 +12,48 @@
> >
{{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }} {{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }}
</button> </button>
<div <button
v-for="match in matches" type="button"
:key="match.meetingId" class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
:class="['shrink-0 whitespace-nowrap', match.completed ? 'italic text-gray-600' : 'font-semibold']" :aria-pressed="paused"
:aria-label="paused ? 'Laufschrift fortsetzen' : 'Laufschrift anhalten'"
@click="paused = !paused"
> >
<span v-if="match.completed">✓ beendet</span> {{ paused ? '▶' : 'Ⅱ' }}
<span </button>
v-else-if="match.hasLiveData" <div
class="text-red-700" class="ticker-viewport min-w-0 flex-1"
>● LIVE</span> :class="{ 'ticker-paused': paused }"
<span >
v-else <div
class="text-gray-600" ref="tickerTrack"
>◌ Keine Live-Daten</span> class="ticker-track"
<span class="mx-2">{{ match.homeTeam }} <strong>{{ match.score }}</strong> {{ match.awayTeam }}</span> :style="{ '--ticker-duration': `${tickerDuration}s` }"
>
<div
v-for="copy in 2"
:key="copy"
class="ticker-group"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="match in matches"
:key="match.meetingId"
:class="['shrink-0 whitespace-nowrap', match.completed ? 'italic text-gray-600' : 'font-semibold']"
>
<span v-if="match.completed">✓ beendet</span>
<span
v-else-if="match.hasLiveData"
class="text-red-700"
>● LIVE</span>
<span
v-else
class="text-gray-600"
>◌ Keine Live-Daten</span>
<span class="mx-2">{{ match.homeTeam }} <strong>{{ match.score }}</strong> {{ match.awayTeam }}</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -41,12 +68,26 @@ const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const matches = ref([]) const matches = ref([])
const soundEnabled = ref(false) const soundEnabled = ref(false)
const paused = ref(false)
const tickerTrack = ref(null)
const tickerDuration = ref(30)
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms'))) const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
let timer let timer
let audioContext let audioContext
let tickerLoaded = false let tickerLoaded = false
let previousStates = new Map() let previousStates = new Map()
// Two equal copies let the track wrap without a visible jump. ResizeObserver
// keeps the speed at 36 px/s when results, fonts or the viewport change.
watch(tickerTrack, (track, _previous, onCleanup) => {
if (!track) return
const observer = new ResizeObserver(() => {
tickerDuration.value = Math.max(1, track.getBoundingClientRect().width / 2 / 36)
})
observer.observe(track)
onCleanup(() => observer.disconnect())
}, { flush: 'post' })
function stateByMeeting(items) { function stateByMeeting(items) {
return new Map(items.map(match => [ return new Map(items.map(match => [
match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`, match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`,
@@ -109,3 +150,44 @@ onMounted(() => {
onUnmounted(() => clearInterval(timer)) onUnmounted(() => clearInterval(timer))
watch(isInternal, load) watch(isInternal, load)
</script> </script>
<style scoped>
.ticker-viewport {
container-type: inline-size;
overflow: hidden;
}
.ticker-track {
display: flex;
width: max-content;
animation: ticker-scroll var(--ticker-duration) linear infinite;
will-change: transform;
}
.ticker-group {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2rem;
min-width: 100cqw;
padding-right: 2rem;
}
.ticker-paused .ticker-track {
animation-play-state: paused;
}
@media (hover: hover) {
.ticker-viewport:hover .ticker-track { animation-play-state: paused; }
}
@keyframes ticker-scroll {
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.ticker-viewport { overflow-x: auto; }
.ticker-track { animation: none; will-change: auto; }
.ticker-group[aria-hidden="true"] { display: none; }
}
</style>

View File

@@ -175,6 +175,9 @@ describe('Config & Profil Endpoints', () => {
id: '1', id: '1',
notificationSettings: { notificationSettings: {
eventsToday: true, eventsToday: true,
liveTickerStarts: true,
liveTickerScoreChanges: true,
liveTickerFinished: true,
selectedTeamSlugs: ['herren-1', 'herren-1', ''], selectedTeamSlugs: ['herren-1', 'herren-1', ''],
selectedTeamSeason: '2025/2026', selectedTeamSeason: '2025/2026',
notificationTime: '07:30' notificationTime: '07:30'
@@ -185,6 +188,9 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
expect(result.settings.eventsToday).toBe(true) expect(result.settings.eventsToday).toBe(true)
expect(result.settings.liveTickerStarts).toBe(true)
expect(result.settings.liveTickerScoreChanges).toBe(true)
expect(result.settings.liveTickerFinished).toBe(true)
expect(result.settings.newEvents).toBe(false) expect(result.settings.newEvents).toBe(false)
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1']) expect(result.settings.selectedTeamSlugs).toEqual(['herren-1'])
expect(result.settings.notificationTime).toBe('07:30') expect(result.settings.notificationTime).toBe('07:30')
@@ -202,6 +208,9 @@ describe('Config & Profil Endpoints', () => {
const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
mockSuccessReadBody({ mockSuccessReadBody({
newEvents: true, newEvents: true,
liveTickerStarts: true,
liveTickerScoreChanges: true,
liveTickerFinished: true,
eventsToday: 'true', eventsToday: 'true',
birthdays: true, birthdays: true,
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '], selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
@@ -218,6 +227,9 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
expect(result.settings.newEvents).toBe(true) expect(result.settings.newEvents).toBe(true)
expect(result.settings.liveTickerStarts).toBe(true)
expect(result.settings.liveTickerScoreChanges).toBe(true)
expect(result.settings.liveTickerFinished).toBe(true)
expect(result.settings.eventsToday).toBe(false) expect(result.settings.eventsToday).toBe(false)
expect(result.settings.birthdays).toBe(true) expect(result.settings.birthdays).toBe(true)
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend']) expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
@@ -227,6 +239,9 @@ describe('Config & Profil Endpoints', () => {
id: '1', id: '1',
notificationSettings: expect.objectContaining({ notificationSettings: expect.objectContaining({
newEvents: true, newEvents: true,
liveTickerStarts: true,
liveTickerScoreChanges: true,
liveTickerFinished: true,
birthdays: true, birthdays: true,
selectedTeamSeason: '2026/2027' selectedTeamSeason: '2026/2027'
}) })