From 9798482fffd828a2c871c2de63d36b70b265e371 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:29:17 +0530 Subject: [PATCH] fix(simkl): refresh settings from activity changes --- .../app/features/simkl/SimklApiClient.kt | 2 +- .../app/features/simkl/SimklAuthModels.kt | 20 +++++++ .../app/features/simkl/SimklAuthRepository.kt | 27 +++++++--- .../app/features/simkl/SimklSyncRepository.kt | 2 + .../app/features/simkl/SimklApiClientTest.kt | 17 ++++++ .../features/simkl/SimklSettingsPolicyTest.kt | 53 +++++++++++++++++++ 6 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt index 8768b5102..9e775b9b0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApiClient.kt @@ -103,7 +103,7 @@ internal class SimklApiClient( url = buildSimklApiUrl(request.path, request.query), headers = simklRequestHeaders( accessToken = token, - contentTypeJson = request.body.isNotEmpty(), + contentTypeJson = request.method == SimklHttpMethod.POST, ), body = request.body, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt index 379f6d045..4d1dab0f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthModels.kt @@ -33,6 +33,8 @@ data class SimklAuthUiState( internal data class SimklStoredAuthState( val username: String? = null, val accountId: Long? = null, + val hasFetchedUserSettings: Boolean = false, + val settingsActivityWatermark: String? = null, val tokenExpiresAtEpochMs: Long? = null, val pendingAuthorizationState: String? = null, val pendingAuthorizationStartedAtEpochMs: Long? = null, @@ -41,6 +43,24 @@ internal data class SimklStoredAuthState( get() = !pendingAuthorizationState.isNullOrBlank() } +internal enum class SimklSettingsRefreshAction { + NONE, + RECORD_WATERMARK, + FETCH, +} + +internal fun simklSettingsRefreshAction( + state: SimklStoredAuthState, + activityWatermark: String?, +): SimklSettingsRefreshAction = when { + activityWatermark.isNullOrBlank() -> SimklSettingsRefreshAction.NONE + activityWatermark == state.settingsActivityWatermark -> SimklSettingsRefreshAction.NONE + state.settingsActivityWatermark == null && state.hasFetchedUserSettings -> { + SimklSettingsRefreshAction.RECORD_WATERMARK + } + else -> SimklSettingsRefreshAction.FETCH +} + internal sealed interface SimklAuthCallback { data class AuthorizationCode( val code: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt index c7da57096..6feed1dcf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAuthRepository.kt @@ -187,7 +187,21 @@ object SimklAuthRepository : TrackingAuthProvider { suspend fun refreshUserSettings(): String? { authorizedAccessToken() ?: return null - return fetchAndStoreUserSettings() + return if (fetchAndStoreUserSettings()) storedState.username else null + } + + internal suspend fun synchronizeUserSettings(activityWatermark: String?) { + authorizedAccessToken() ?: return + when (simklSettingsRefreshAction(storedState, activityWatermark)) { + SimklSettingsRefreshAction.NONE -> Unit + SimklSettingsRefreshAction.RECORD_WATERMARK -> { + storedState = storedState.copy(settingsActivityWatermark = activityWatermark) + persistMetadata() + } + SimklSettingsRefreshAction.FETCH -> { + fetchAndStoreUserSettings(activityWatermark) + } + } } private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) = @@ -261,30 +275,31 @@ object SimklAuthRepository : TrackingAuthProvider { SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED) } - private suspend fun fetchAndStoreUserSettings(): String? { + private suspend fun fetchAndStoreUserSettings(activityWatermark: String? = null): Boolean { val response = try { SimklApi.client.execute( SimklApiRequest( method = SimklHttpMethod.POST, path = "/users/settings", - body = "{}", ), ) } catch (error: CancellationException) { throw error } catch (error: Throwable) { log.w { "Failed to fetch Simkl user settings: ${error.message}" } - return null + return false } val settings = runCatching { json.decodeFromString(response.body) } - .getOrNull() ?: return null + .getOrNull() ?: return false storedState = storedState.copy( username = settings.user?.name, accountId = settings.account?.id, + hasFetchedUserSettings = true, + settingsActivityWatermark = activityWatermark ?: storedState.settingsActivityWatermark, ) persistMetadata() publish(error = null) - return storedState.username + return true } private fun loadFromDisk() { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt index 76765310d..19494aa19 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -130,6 +130,8 @@ object SimklSyncRepository : TrackingProfileStore { return } + if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return + SimklAuthRepository.synchronizeUserSettings(result.activities?.settings?.all) if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return SimklSyncStorage.savePayload(json.encodeToString(result)) _state.value = SimklSyncUiState( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt index 16189f341..2aa849cad 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklApiClientTest.kt @@ -69,6 +69,23 @@ class SimklApiClientTest { assertTrue(request.headers.getValue("User-Agent").contains('/')) } + @Test + fun `bodyless post sends json content type without inventing a payload`() = runBlocking { + val engine = RecordingEngine(response(200)) + val harness = TestHarness(engine) + + harness.client.execute( + SimklApiRequest( + method = SimklHttpMethod.POST, + path = "/users/settings", + ), + ) + + val request = engine.requests.single() + assertEquals("", request.body) + assertEquals("application/json", request.headers["Content-Type"]) + } + @Test fun `single use unauthenticated posts keep metadata and never retry`() = runBlocking { val engine = RecordingEngine(response(503), response(200)) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt new file mode 100644 index 000000000..2a8911626 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklSettingsPolicyTest.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.simkl + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SimklSettingsPolicyTest { + @Test + fun `missing activity watermark does not fetch settings`() { + assertEquals( + SimklSettingsRefreshAction.NONE, + simklSettingsRefreshAction(SimklStoredAuthState(), null), + ) + } + + @Test + fun `first activity watermark is recorded after sign in settings fetch`() { + assertEquals( + SimklSettingsRefreshAction.RECORD_WATERMARK, + simklSettingsRefreshAction( + state = SimklStoredAuthState(hasFetchedUserSettings = true), + activityWatermark = "2026-07-22T08:48:07Z", + ), + ) + } + + @Test + fun `changed activity watermark fetches settings and matching watermark skips`() { + val state = SimklStoredAuthState( + hasFetchedUserSettings = true, + settingsActivityWatermark = "2026-07-22T08:48:07Z", + ) + + assertEquals( + SimklSettingsRefreshAction.NONE, + simklSettingsRefreshAction(state, "2026-07-22T08:48:07Z"), + ) + assertEquals( + SimklSettingsRefreshAction.FETCH, + simklSettingsRefreshAction(state, "2026-07-22T09:12:30Z"), + ) + } + + @Test + fun `legacy auth state fetches once before recording a watermark`() { + assertEquals( + SimklSettingsRefreshAction.FETCH, + simklSettingsRefreshAction( + state = SimklStoredAuthState(username = "Nuvio User"), + activityWatermark = "2026-07-22T08:48:07Z", + ), + ) + } +}