feat: add homepage components and API for settings and spielplan options

- Introduced new Vue components for homepage teasers: HomeLinksTeaser, HomeSpielplanTeamWidget, HomeTrainingTeaser, and HomeVereinsmeisterschaftenTeaser.
- Created XML layout for tablet app window dump.
- Implemented API endpoints for fetching and updating homepage settings.
- Added API for retrieving spielplan options, including team extraction logic.
This commit is contained in:
Torsten Schulz (local)
2026-05-29 15:37:45 +02:00
parent 1ea9596006
commit b8bdbf0a8d
39 changed files with 3867 additions and 163 deletions

View File

@@ -1,9 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name="dagger.hilt.android.testing.HiltTestApplication"
android:allowBackup="false">
<application android:allowBackup="false">
</application>
</manifest>

View File

@@ -41,6 +41,7 @@ object TestHiltModules {
"authStatus" -> Response.success(AuthStatusResponse(isLoggedIn = false))
"publicNews" -> Response.success(de.harheimertc.data.NewsPublicResponse(news = listOf()))
"memberNews" -> Response.success(de.harheimertc.data.NewsResponse(success = true, news = listOf()))
"passwordResetDiagnostics" -> Response.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
else -> throw UnsupportedOperationException("ApiService method not implemented in test double: ${method.name}")
}
}

View File

@@ -0,0 +1,390 @@
package de.harheimertc.ui.screens.cms
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.navigation.compose.rememberNavController
import com.squareup.moshi.Moshi
import de.harheimertc.data.ApiService
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.BirthdaysResponse
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequest
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.ContactResponse
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.MembersResponse
import de.harheimertc.data.MembershipRequest
import de.harheimertc.data.MembershipResponse
import de.harheimertc.data.NewsletterCreateRequest
import de.harheimertc.data.NewsletterCreateResponse
import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.NewsletterSendResponse
import de.harheimertc.data.NewsletterSubscriptionRequest
import de.harheimertc.data.NewsPublicResponse
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.PasskeyAuthenticationOptionsRequest
import de.harheimertc.data.PasskeyRegistrationOptionsRequest
import de.harheimertc.data.PasskeysResponse
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.ProfileResponse
import de.harheimertc.data.ProfileUpdateRequest
import de.harheimertc.data.PublicGalleryImageDto
import de.harheimertc.data.GalleryListResponse
import de.harheimertc.data.GalleryUploadResponse
import de.harheimertc.data.RefreshRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.RemovePasskeyRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TeamTableResponse
import de.harheimertc.data.TermineResponse
import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.ResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.Response
@RunWith(AndroidJUnit4::class)
class CmsExistingScreensSmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsDashboard_renders() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsDashboardScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Vereinsmeisterschaften", substring = true).assertExists()
}
@Test
fun cmsStartseite_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsInhalte_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsInhalteScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Inhalte speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsVereinsmeisterschaften_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsVereinsmeisterschaftenScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.saveCsvCalls)
}
@Test
fun cmsSportbetrieb_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsSportbetriebScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsEinstellungen_saveWorks() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsEinstellungenScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertTrue(api.updateConfigCalls >= 1)
}
@Test
fun cmsMitgliederverwaltung_clickFreischalten() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsMitgliederverwaltungScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Freischalten").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.updateUserActiveCalls)
}
@Test
fun cmsBenutzer_clickRollenSpeichern() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsBenutzerScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Rollen").performClick()
composeTestRule.onNodeWithText("Speichern").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.updateUserRolesCalls)
}
@Test
fun cmsContactRequests_replySenden() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsContactRequestsScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Antworten").performClick()
composeTestRule.onNode(hasSetTextAction()).performTextInput("Kurze Testantwort")
composeTestRule.onNodeWithText("Senden").performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.replyContactCalls)
}
@Test
fun cmsNewsletter_createAndSave() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsNewsletterScreen(nav, showBackNavigation = false, viewModel = viewModel, canWriteOverride = true)
}
composeTestRule.onNodeWithText("Newsletter erstellen").performClick()
composeTestRule.onNode(hasSetTextAction()).performTextInput("Testnewsletter")
composeTestRule.onAllNodes(hasText("Speichern")).onFirst().performClick()
composeTestRule.waitForIdle()
assertEquals(1, api.createNewsletterCalls)
}
@Test
fun cmsPasswordResetDiagnostics_renders() {
val api = RecordingApiService()
val viewModel = createViewModel(api)
renderWithState(viewModel)
composeTestRule.setContent {
val nav = rememberNavController()
CmsPasswordResetDiagnosticsScreen(nav, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.onNodeWithText("Passwort-Reset-Diagnose", substring = true).assertExists()
}
private fun createViewModel(api: RecordingApiService): CmsViewModel {
val context = ApplicationProvider.getApplicationContext<Context>()
val cache = SecureOfflineCache(context, Moshi.Builder().build())
val repository = CmsRepository(api, cache)
return CmsViewModel(repository)
}
private fun renderWithState(viewModel: CmsViewModel) {
val readyState = CmsUiState(
loading = false,
saving = false,
error = null,
message = null,
config = ConfigResponse(),
users = listOf(
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
),
contactRequests = listOf(
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
),
newsletters = listOf(
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
),
newsletterGroups = listOf(
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
),
passwordResetAttempts = listOf(
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
),
news = emptyList(),
meisterschaften = listOf(
MeisterschaftResult(year = "2025", category = "Herren", rank = "1", playerOne = "Erika Muster", playerTwo = "", note = "Titel verteidigt", imageOne = "", imageTwo = ""),
),
)
val field = CmsViewModel::class.java.getDeclaredField("_state")
field.isAccessible = true
@Suppress("UNCHECKED_CAST")
(field.get(viewModel) as MutableStateFlow<CmsUiState>).value = readyState
}
}
private class RecordingApiService : ApiService {
var updateConfigCalls = 0
var saveCsvCalls = 0
var updateUserActiveCalls = 0
var updateUserRolesCalls = 0
var replyContactCalls = 0
var createNewsletterCalls = 0
private val config = ConfigResponse()
private val users = listOf(
CmsUserDto(id = "pending-1", name = "Pending User", email = "pending@example.com", active = false, roles = emptyList()),
CmsUserDto(id = "active-1", name = "Active User", email = "active@example.com", active = true, roles = listOf("vorstand")),
)
private val contactRequests = listOf(
ContactRequestDto(id = "request-1", name = "Kontakt Test", email = "kontakt@example.com", message = "Bitte melden", status = "offen"),
)
private val newsletters = listOf(
NewsletterDto(id = "newsletter-1", title = "Sommerinfo", subject = "Sommerinfo", status = "draft"),
)
private val groups = listOf(
NewsletterGroupDto(id = "group-1", name = "Mitglieder", description = "Alle Mitglieder"),
)
private val diagnostics = listOf(
PasswordResetAttemptDto(requestId = "diag-1", emailMasked = "m***@example.com", failed = true),
)
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun mannschaften(season: String?): Response<ResponseBody> = Response.success(null)
override suspend fun config(): Response<ConfigResponse> = Response.success(config)
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> {
updateConfigCalls++
return Response.success(request)
}
override suspend fun spielsysteme(): Response<ResponseBody> = Response.success(null)
override suspend fun vereinsmeisterschaften(): Response<ResponseBody> = Response.success(ResponseBody.create(null, "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung,imageFilename1,imageFilename2\n\"2025\",\"Herren\",\"1\",\"Erika Muster\",\"\",\"Titel verteidigt\",\"\",\"\""))
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> {
saveCsvCalls++
return Response.success(SaveCsvResponse(success = true, message = "CSV gespeichert"))
}
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<ResponseBody> = Response.success(null)
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<ResponseBody> = Response.success(null)
override suspend fun passkeyLogin(request: RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<ResponseBody> = Response.success(null)
override suspend fun registerPasskey(request: RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse(users = users))
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> {
updateUserRolesCalls++
return Response.success(AuthMessageResponse(success = true, message = "Rollen aktualisiert"))
}
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> {
updateUserActiveCalls++
return Response.success(AuthMessageResponse(success = true, message = "Status aktualisiert"))
}
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(contactRequests)
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<ContactResponse> {
replyContactCalls++
return Response.success(ContactResponse(ok = true, message = "Antwort versendet"))
}
override suspend fun toggleContactRequestStatus(id: String): Response<ContactResponse> = Response.success(ContactResponse(ok = true, message = "Status aktualisiert"))
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = newsletters))
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> {
createNewsletterCalls++
return Response.success(NewsletterCreateResponse(success = true, message = "Newsletter gespeichert"))
}
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = groups))
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun passwordResetDiagnostics(
email: String?,
failedOnly: Boolean,
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse(attempts = diagnostics))
}

View File

@@ -0,0 +1,122 @@
package de.harheimertc.ui.screens.cms
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.navigation.compose.rememberNavController
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.squareup.moshi.Moshi
import de.harheimertc.data.ApiService
import de.harheimertc.data.CmsUsersResponse
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsResponse
import de.harheimertc.data.NewsletterGroupsResponse
import de.harheimertc.data.NewsletterListResponse
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.repositories.CmsRepository
import okhttp3.ResponseBody
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import retrofit2.Response
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
@RunWith(AndroidJUnit4::class)
class CmsPasswordResetDiagnosticsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun diagnosticsScreen_showsFilterAndAttemptDetails() {
val api = createDiagnosticsApiService()
val context = ApplicationProvider.getApplicationContext<Context>()
val cache = SecureOfflineCache(context, Moshi.Builder().build())
val repo = CmsRepository(api, cache)
val viewModel = CmsViewModel(repo)
composeTestRule.setContent {
val navController = rememberNavController()
CmsPasswordResetDiagnosticsScreen(navController, showBackNavigation = false, viewModel = viewModel)
}
composeTestRule.waitUntil(15_000) {
try {
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
true
} catch (_: Throwable) {
false
}
}
composeTestRule.onNodeWithText("Nur Auffälligkeiten", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Reset-Vorgänge", useUnmergedTree = true).assertExists()
composeTestRule.onNodeWithText("Aktualisieren", useUnmergedTree = true).assertExists()
// Trigger a manual refresh to validate the main interaction path.
composeTestRule.onNodeWithText("Prüfen", useUnmergedTree = true).performClick()
composeTestRule.waitForIdle()
}
private fun createDiagnosticsApiService(): ApiService {
val attempt = PasswordResetAttemptDto(
requestId = "req-1",
startedAt = "2026-05-29T10:15:00Z",
emailMasked = "m***@example.com",
ip = "127.0.0.1",
failed = true,
steps = listOf(
PasswordResetStepDto(
ts = "2026-05-29T10:15:01Z",
step = "mail_configuration",
status = "failed",
reason = "smtp_credentials_missing",
),
),
)
val handler = InvocationHandler { _, method: Method, _ ->
when (method.name) {
"config" -> Response.success(ConfigResponse())
"users" -> Response.success(CmsUsersResponse())
"cmsUsers" -> Response.success(CmsUsersResponse())
"contactRequests" -> Response.success(listOf<ContactRequestDto>())
"newsletters" -> Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
"newsletterGroups" -> Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
"memberNews" -> Response.success(NewsResponse(success = true, news = listOf(NewsDto(id = 1, title = "N", content = "C"))))
"passwordResetDiagnostics" -> Response.success(
PasswordResetDiagnosticsResponse(
retentionHours = 72,
searchedEmail = "",
matchingUsers = listOf(
de.harheimertc.data.PasswordResetMatchingUserDto(
id = "u1",
name = "Max Muster",
email = "max@example.com",
active = true,
),
),
attempts = listOf(attempt),
),
)
"vereinsmeisterschaften" -> Response.success(ResponseBody.create(null, "Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung\n"))
else -> throw UnsupportedOperationException("Unhandled ApiService method in test: ${method.name}")
}
}
return Proxy.newProxyInstance(
ApiService::class.java.classLoader,
arrayOf(ApiService::class.java),
handler,
) as ApiService
}
}

View File

@@ -0,0 +1,156 @@
package de.harheimertc.ui.screens.cms
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.core.app.ApplicationProvider
import com.squareup.moshi.Moshi
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertTrue
import org.junit.runner.RunWith
import retrofit2.Response
import okhttp3.MultipartBody
import okhttp3.RequestBody
import de.harheimertc.data.*
import kotlinx.coroutines.flow.MutableStateFlow
import de.harheimertc.repositories.CmsRepository
import androidx.navigation.compose.rememberNavController
@RunWith(AndroidJUnit4::class)
class CmsStartseiteSmokeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun cmsStartseite_rendersWithDefaultState() {
// prepare a minimal fake ApiService that returns empty/neutral responses
val fakeApi = object : ApiService {
override suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>> = Response.success(emptyList())
override suspend fun postContact(req: ContactRequest): Response<ContactResponse> = Response.success(ContactResponse(ok = true))
override suspend fun galerieList(page: Int, perPage: Int): Response<GalleryListResponse> = Response.success(GalleryListResponse())
override suspend fun uploadGalleryImage(image: MultipartBody.Part, title: RequestBody, description: RequestBody, isPublic: RequestBody): Response<GalleryUploadResponse> = Response.success(GalleryUploadResponse())
override suspend fun termine(): Response<TermineResponse> = Response.success(TermineResponse())
override suspend fun spielplan(season: String?): Response<SpielplanResponse> = Response.success(SpielplanResponse())
override suspend fun spielplanTable(team: String, season: String?): Response<TeamTableResponse> = Response.success(TeamTableResponse())
override suspend fun publicNews(): Response<NewsPublicResponse> = Response.success(NewsPublicResponse())
override suspend fun memberNews(): Response<NewsResponse> = Response.success(NewsResponse(success = true, news = emptyList()))
override suspend fun saveNews(request: NewsSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true, message = "ok"))
override suspend fun deleteNews(id: Int): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun mannschaften(season: String?): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun config(): Response<ConfigResponse> = Response.success(ConfigResponse())
override suspend fun updateConfig(request: ConfigResponse): Response<ConfigResponse> = Response.success(request)
override suspend fun spielsysteme(): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun vereinsmeisterschaften(): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun saveCsv(request: SaveCsvRequest): Response<SaveCsvResponse> = Response.success(SaveCsvResponse(success = true))
override suspend fun generateMembershipPdf(request: MembershipRequest): Response<MembershipResponse> = Response.success(MembershipResponse())
override suspend fun downloadMembershipPdf(downloadUrl: String): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun login(request: LoginRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun logout(request: LogoutRequest): Response<Unit> = Response.success(Unit)
override suspend fun refresh(request: RefreshRequest): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun authStatus(): Response<AuthStatusResponse> = Response.success(AuthStatusResponse())
override suspend fun resetPassword(request: ResetPasswordRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun register(request: RegistrationRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun passkeyAuthenticationOptions(request: PasskeyAuthenticationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun passkeyLogin(request: okhttp3.RequestBody): Response<LoginResponse> = Response.success(LoginResponse())
override suspend fun passkeys(): Response<PasskeysResponse> = Response.success(PasskeysResponse())
override suspend fun passkeyRegistrationOptions(request: PasskeyRegistrationOptionsRequest): Response<okhttp3.ResponseBody> = Response.success(null)
override suspend fun registerPasskey(request: okhttp3.RequestBody): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun removePasskey(request: RemovePasskeyRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun profile(): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun updateProfile(request: ProfileUpdateRequest): Response<ProfileResponse> = Response.success(ProfileResponse())
override suspend fun birthdays(): Response<BirthdaysResponse> = Response.success(BirthdaysResponse())
override suspend fun members(): Response<MembersResponse> = Response.success(MembersResponse())
override suspend fun saveMember(request: ApiService.MemberSaveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun deleteMember(body: Map<String, String>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun bulkImportMembers(request: ApiService.BulkImportRequest): Response<ApiService.BulkImportResponse> = Response.success(ApiService.BulkImportResponse())
override suspend fun toggleMannschaftsspieler(body: Map<String, String>): Response<Map<String, Any>> = Response.success(emptyMap())
override suspend fun cmsUsers(): Response<CmsUsersResponse> = Response.success(CmsUsersResponse())
override suspend fun updateUserRoles(request: ApiService.UpdateUserRolesRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun updateUserActive(request: ApiService.UpdateUserActiveRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun resendInvite(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse())
override suspend fun contactRequests(): Response<List<ContactRequestDto>> = Response.success(emptyList())
override suspend fun replyToContactRequest(id: String, request: ApiService.ContactReplyRequest): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
override suspend fun toggleContactRequestStatus(id: String): Response<de.harheimertc.data.ContactResponse> = Response.success(de.harheimertc.data.ContactResponse(ok = true))
override suspend fun newsletters(): Response<NewsletterListResponse> = Response.success(NewsletterListResponse(success = true, newsletters = emptyList()))
override suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true, groups = emptyList()))
override suspend fun createNewsletterGroup(request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun updateNewsletterGroup(id: String, request: Map<String, Any?>): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun deleteNewsletterGroup(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun createNewsletter(request: NewsletterCreateRequest): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun updateNewsletter(id: String, request: Map<String, Any?>): Response<NewsletterCreateResponse> = Response.success(NewsletterCreateResponse(success = true))
override suspend fun sendNewsletter(id: String): Response<NewsletterSendResponse> = Response.success(NewsletterSendResponse(success = true))
override suspend fun deleteNewsletter(id: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> = Response.success(NewsletterGroupsResponse(success = true))
override suspend fun subscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun unsubscribeNewsletter(request: NewsletterSubscriptionRequest): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun confirmNewsletter(token: String): Response<AuthMessageResponse> = Response.success(AuthMessageResponse(success = true))
override suspend fun passwordResetDiagnostics(
email: String?,
failedOnly: Boolean,
): Response<PasswordResetDiagnosticsResponse> = Response.success(PasswordResetDiagnosticsResponse())
}
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val moshi = Moshi.Builder().build()
val cache = SecureOfflineCache(context, moshi)
val repo = CmsRepository(fakeApi, cache)
val vm = de.harheimertc.ui.screens.cms.CmsViewModel(repo)
// set a ready state to avoid waiting for async network loads in Vm.init
val readyState = de.harheimertc.ui.screens.cms.CmsUiState(
loading = false,
saving = false,
error = null,
message = null,
config = ConfigResponse(),
users = emptyList(),
contactRequests = emptyList(),
newsletters = emptyList(),
newsletterGroups = emptyList(),
passwordResetAttempts = emptyList(),
news = emptyList(),
)
try {
val field = de.harheimertc.ui.screens.cms.CmsViewModel::class.java.getDeclaredField("_state")
field.isAccessible = true
val current = field.get(vm) as? MutableStateFlow<*>
if (current is MutableStateFlow<*>) {
@Suppress("UNCHECKED_CAST")
(current as MutableStateFlow<de.harheimertc.ui.screens.cms.CmsUiState>).value = readyState
}
} catch (_: Throwable) { /* best-effort, continue */ }
composeTestRule.setContent {
val nav = rememberNavController()
CmsStartseiteScreen(nav, showBackNavigation = false, viewModel = vm)
}
// dump semantics tree for debugging
try {
composeTestRule.onRoot().printToLog("CmsStartseiteSmokeTest-SEMTREE")
} catch (_: Throwable) { }
// wait for the main title and info rows to appear
fun waitForText(text: String, timeoutMs: Long = 20000L) {
composeTestRule.waitUntil(timeoutMs) {
try {
composeTestRule.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes().isNotEmpty()
} catch (_: AssertionError) { false }
}
}
waitForText("Startseite")
waitForText("Öffentliche")
// basic assertions (use substring matching)
assertTrue(composeTestRule.onAllNodes(hasText("Startseite", substring = true)).fetchSemanticsNodes().isNotEmpty())
assertTrue(composeTestRule.onAllNodes(hasText("Öffentliche", substring = true)).fetchSemanticsNodes().isNotEmpty())
}
}

View File

@@ -0,0 +1,144 @@
package de.harheimertc.ui.screens.cms
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CmsUiAutomatorClickTest {
@Test
fun clickThroughExistingCmsPages_andTrySave() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val device = UiDevice.getInstance(instrumentation)
val packageName = "de.harheimertc.local"
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
assertNotNull("Launch-Intent fuer de.harheimertc.local nicht gefunden", launchIntent)
context.startActivity(launchIntent)
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 15000)
clickText(device, "Intern")
clickText(device, "CMS")
openCmsCard(device, "Startseite")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Inhalte")
clickIfPresent(device, "Inhalte speichern")
backToCmsDashboard(device)
openCmsCard(device, "Vereinsmeisterschaften")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Sportbetrieb")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Einstellungen")
clickIfPresent(device, "Speichern")
backToCmsDashboard(device)
openCmsCard(device, "Mitgliederverwaltung")
clickIfPresent(device, "Freischalten")
backToCmsDashboard(device)
openCmsCard(device, "Kontaktanfragen")
clickIfPresent(device, "Antworten")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
openCmsCard(device, "Newsletter")
clickIfPresent(device, "Newsletter erstellen")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
if (openCmsCardIfAvailable(device, "Benutzer")) {
clickIfPresent(device, "Rollen")
clickIfPresent(device, "Abbrechen")
backToCmsDashboard(device)
}
openCmsCardIfAvailable(device, "Passwort-Reset-Diagnose")
// Wenn wir am Ende noch im App-Paket sind, ist der Flow nicht gecrasht.
device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000)
}
private fun openCmsCard(device: UiDevice, label: String) {
if (!clickIfPresent(device, label, 2500) && !clickTextWithScroll(device, label)) {
clickText(device, label)
}
}
private fun openCmsCardIfAvailable(device: UiDevice, label: String): Boolean {
if (clickIfPresent(device, label, 1500)) return true
return clickTextWithScroll(device, label)
}
private fun backToCmsDashboard(device: UiDevice) {
if (!clickIfPresent(device, "CMS", 3000)) {
device.pressBack()
device.waitForIdle()
}
}
private fun clickText(device: UiDevice, text: String, timeoutMs: Long = 10000): UiObject2 {
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
requireNotNull(obj) { "Text nicht gefunden: $text" }
obj.click()
device.waitForIdle()
return obj
}
private fun clickIfPresent(device: UiDevice, text: String, timeoutMs: Long = 1500): Boolean {
val obj = device.wait(Until.findObject(By.textContains(text)), timeoutMs)
if (obj != null) {
obj.click()
device.waitForIdle()
return true
}
return false
}
private fun clickTextWithScroll(device: UiDevice, text: String, maxSwipes: Int = 5): Boolean {
if (clickIfPresent(device, text, 1500)) return true
repeat(maxSwipes) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.8).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.25).toInt(),
24,
)
device.waitForIdle()
if (clickIfPresent(device, text, 1200)) return true
}
repeat(maxSwipes) {
device.swipe(
device.displayWidth / 2,
(device.displayHeight * 0.25).toInt(),
device.displayWidth / 2,
(device.displayHeight * 0.8).toInt(),
24,
)
device.waitForIdle()
if (clickIfPresent(device, text, 1200)) return true
}
return false
}
}