Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.garfiec.librechat.feature.auth.navigation.Login
import com.garfiec.librechat.feature.auth.navigation.ServerUrl
import com.garfiec.librechat.feature.chat.navigation.Chat
import com.garfiec.librechat.feature.chat.navigation.NewChat
import com.garfiec.librechat.feature.settings.navigation.ProviderKeys
import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed
import com.garfiec.librechat.shared.navigation.Navigator
import org.junit.Assert.assertEquals
Expand Down Expand Up @@ -136,4 +137,44 @@ class NavigatorTest {
navigator.navigateToChat()
assertEquals(listOf(NewChat), navigator.backStack.toList())
}

@Test
fun `navigateToProviderKeys adds route when not on top`() {
val navigator = createNavigator(NewChat)
navigator.navigateToProviderKeys("openAI")
assertEquals(
listOf(NewChat, ProviderKeys(pendingDialogEndpoint = "openAI")),
navigator.backStack.toList(),
)
}

@Test
fun `navigateToProviderKeys is no-op when same endpoint already on top`() {
val navigator = createNavigator(NewChat, ProviderKeys(pendingDialogEndpoint = "openAI"))
navigator.navigateToProviderKeys("openAI")
assertEquals(
listOf(NewChat, ProviderKeys(pendingDialogEndpoint = "openAI")),
navigator.backStack.toList(),
)
}

@Test
fun `navigateToProviderKeys replaces top when different endpoint already on top`() {
val navigator = createNavigator(NewChat, ProviderKeys(pendingDialogEndpoint = "openAI"))
navigator.navigateToProviderKeys("anthropic")
assertEquals(
listOf(NewChat, ProviderKeys(pendingDialogEndpoint = "anthropic")),
navigator.backStack.toList(),
)
}

@Test
fun `navigateToProviderKeys with null endpoint dedupes against null-top`() {
val navigator = createNavigator(NewChat, ProviderKeys(pendingDialogEndpoint = null))
navigator.navigateToProviderKeys(null)
assertEquals(
listOf(NewChat, ProviderKeys(pendingDialogEndpoint = null)),
navigator.backStack.toList(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package com.garfiec.librechat.core.data.repository

import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.model.endpoint.KeyInvalidation
import com.garfiec.librechat.core.model.endpoint.KeyState
import com.garfiec.librechat.core.model.request.UpdateKeyRequest
import com.garfiec.librechat.core.model.response.KeyExpiryResponse
import com.garfiec.librechat.core.network.api.KeysApi
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class KeyRepositoryImplTest {

private val keysApi = mockk<KeysApi>(relaxed = true)

private val sut = KeyRepositoryImpl(keysApi = keysApi)

@Test
fun `updateKey returns Success of Unit`() = runTest {
val request = UpdateKeyRequest(
name = "openAI",
value = """{"apiKey":"sk-x","baseURL":""}""",
expiresAt = "2026-05-01T00:00:00Z",
)
coEvery { keysApi.updateKey(request) } returns Unit

val result = sut.updateKey(request)

assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) { keysApi.updateKey(request) }
}

@Test
fun `updateKey emits keyInvalidations on success`() = runTest {
val request = UpdateKeyRequest(
name = "openAI",
value = """{"apiKey":"sk-x","baseURL":""}""",
expiresAt = "2026-05-01T00:00:00Z",
)
coEvery { keysApi.updateKey(request) } returns Unit

val emitted = mutableListOf<KeyInvalidation>()
val collectorJob = launch { sut.keyInvalidations.collect { emitted.add(it) } }
advanceUntilIdle()

sut.updateKey(request)
advanceUntilIdle()

assertThat(emitted).containsExactly(KeyInvalidation.ByName("openAI"))
collectorJob.cancel()
}

@Test
fun `deleteKey emits keyInvalidations on success`() = runTest {
coEvery { keysApi.deleteKey("openAI") } returns Unit

val emitted = mutableListOf<KeyInvalidation>()
val collectorJob = launch { sut.keyInvalidations.collect { emitted.add(it) } }
advanceUntilIdle()

sut.deleteKey("openAI")
advanceUntilIdle()

assertThat(emitted).containsExactly(KeyInvalidation.ByName("openAI"))
collectorJob.cancel()
}

@Test
fun `deleteAllKeys emits KeyInvalidation All on success`() = runTest {
coEvery { keysApi.deleteAllKeys() } returns Unit

val emitted = mutableListOf<KeyInvalidation>()
val collectorJob = launch { sut.keyInvalidations.collect { emitted.add(it) } }
advanceUntilIdle()

sut.deleteAllKeys()
advanceUntilIdle()

assertThat(emitted).containsExactly(KeyInvalidation.All)
collectorJob.cancel()
}

@Test
fun `updateKey does not emit on failure`() = runTest {
val request = UpdateKeyRequest(name = "openAI", value = "{}", expiresAt = "never")
coEvery { keysApi.updateKey(request) } throws RuntimeException("boom")

val emitted = mutableListOf<KeyInvalidation>()
val collectorJob = launch { sut.keyInvalidations.collect { emitted.add(it) } }
advanceUntilIdle()

val result = sut.updateKey(request)
advanceUntilIdle()

assertThat(result).isInstanceOf(Result.Error::class.java)
assertThat(emitted).isEmpty()
collectorJob.cancel()
}

@Test
fun `fetchKeyState maps null wire to Unset`() = runTest {
coEvery { keysApi.getKeyExpiry("openAI") } returns KeyExpiryResponse(expiresAt = null)

val result = sut.fetchKeyState("openAI")

assertThat(result).isInstanceOf(Result.Success::class.java)
assertThat((result as Result.Success).data).isEqualTo(KeyState.Unset)
}

@Test
fun `fetchKeyState maps empty wire to Unset`() = runTest {
coEvery { keysApi.getKeyExpiry("openAI") } returns KeyExpiryResponse(expiresAt = "")

val result = sut.fetchKeyState("openAI")

assertThat((result as Result.Success).data).isEqualTo(KeyState.Unset)
}

@Test
fun `fetchKeyState maps never literal to Set with neverExpires true`() = runTest {
coEvery { keysApi.getKeyExpiry("openAI") } returns KeyExpiryResponse(expiresAt = "never")

val result = sut.fetchKeyState("openAI")

val state = (result as Result.Success).data
assertThat(state).isInstanceOf(KeyState.Set::class.java)
val set = state as KeyState.Set
assertThat(set.neverExpires).isTrue()
assertThat(set.expiresAt).isNull()
assertThat(set.wire).isEqualTo("never")
}

@Test
fun `fetchKeyState maps future ISO timestamp to Set`() = runTest {
val future = (Clock.System.now() + 7.days).toString()
coEvery { keysApi.getKeyExpiry("openAI") } returns KeyExpiryResponse(expiresAt = future)

val result = sut.fetchKeyState("openAI")

val state = (result as Result.Success).data
assertThat(state).isInstanceOf(KeyState.Set::class.java)
val set = state as KeyState.Set
assertThat(set.neverExpires).isFalse()
assertThat(set.expiresAt).isNotNull()
assertThat(set.wire).isEqualTo(future)
}

@Test
fun `fetchKeyState maps past ISO timestamp to Expired`() = runTest {
val past = (Clock.System.now() - 1.days).toString()
coEvery { keysApi.getKeyExpiry("openAI") } returns KeyExpiryResponse(expiresAt = past)

val result = sut.fetchKeyState("openAI")

assertThat((result as Result.Success).data).isEqualTo(KeyState.Expired)
}

@Test
fun `fetchKeyState propagates network errors as Result Error without swallowing`() = runTest {
coEvery { keysApi.getKeyExpiry("openAI") } throws RuntimeException("boom")

val result = sut.fetchKeyState("openAI")

// Error must propagate as Result.Error so callers (chat fail-closed,
// settings unwrap to Unset) can decide; the impl must not silently
// map error -> Unset and discard the cause.
assertThat(result).isInstanceOf(Result.Error::class.java)
}

@Test
fun `fetchKeyState maps malformed wire to Unset`() = runTest {
coEvery { keysApi.getKeyExpiry("openAI") } returns
KeyExpiryResponse(expiresAt = "not-a-timestamp")

val result = sut.fetchKeyState("openAI")

assertThat((result as Result.Success).data).isEqualTo(KeyState.Unset)
}

@Test
fun `keyInvalidations does not replay historical events to late subscribers`() = runTest {
// replay=0 contract: a fresh subscriber attaching AFTER a prior
// updateKey/deleteKey/deleteAllKeys does NOT receive the historical event.
// Justification: every new ChatViewModel would otherwise trigger a fan-out
// recompute on subscription even when the user never mutated a key.
val request = UpdateKeyRequest(name = "openAI", value = "{}", expiresAt = "never")
coEvery { keysApi.updateKey(request) } returns Unit

// Fire the mutation BEFORE any subscriber attaches.
sut.updateKey(request)
advanceUntilIdle()

// Late subscriber attaches.
val emitted = mutableListOf<KeyInvalidation>()
val collectorJob = launch { sut.keyInvalidations.collect { emitted.add(it) } }
advanceUntilIdle()

assertThat(emitted).isEmpty()
collectorJob.cancel()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,30 @@ object EndpointClassifier {
* - endpointType: prefers EndpointConfig.type from /api/config; falls back to
* the endpoint name itself if it's a known built-in (covers cold-start
* race where /api/config hasn't loaded); otherwise "custom".
* - key: "never" when the endpoint is user_provided (matches web's
* getExpiry() returning expiresAt || "never"). null otherwise so the
* field is omitted from the wire body. Time-limited keys are a follow-up.
* - key: when the endpoint is user_provided (`userProvide` or `userProvideURL`),
* passes through the pre-fetched [keyExpiry] (an ISO timestamp from
* `GET /api/keys?name=<endpoint>` or the literal string `"never"`); falls
* back to `"never"` when no key is stored. Null otherwise so the field is
* omitted from the wire body.
* - modelDisplayLabel: from config; falls back to the endpoint name.
*
* [keyExpiry] is pre-fetched at the call site (see ChatViewModel /
* ModelSelectionDelegate) so this function stays pure and synchronous.
*/
fun classify(endpointName: String, configs: Map<String, EndpointConfig>): EndpointDispatch {
fun classify(
endpointName: String,
configs: Map<String, EndpointConfig>,
keyExpiry: String?,
): EndpointDispatch {
val config = configs[endpointName]
val endpointType = config?.type
?: endpointName.takeIf { it in EModelEndpoint.BUILT_IN_NAMES }
?: "custom"
val key = if (config?.userProvide == true) "never" else null
val key = if (config?.userProvide == true || config?.userProvideURL == true) {
keyExpiry ?: "never"
} else {
null
}
val modelDisplayLabel = config?.modelDisplayLabel ?: endpointName
return EndpointDispatch(endpointType, key, modelDisplayLabel)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
package com.garfiec.librechat.core.data.repository

import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.model.UserKey
import com.garfiec.librechat.core.model.endpoint.KeyInvalidation
import com.garfiec.librechat.core.model.endpoint.KeyState
import com.garfiec.librechat.core.model.request.UpdateKeyRequest
import kotlinx.coroutines.flow.SharedFlow

interface KeyRepository {
suspend fun getKeyExpiry(): Result<List<UserKey>>
suspend fun updateKey(request: UpdateKeyRequest): Result<UserKey>
/**
* Emits a [KeyInvalidation] whenever a stored key is mutated:
* [KeyInvalidation.ByName] for [updateKey] / [deleteKey], or [KeyInvalidation.All]
* for [deleteAllKeys]. Observers (e.g. the chat-side `EndpointKeyStatusDelegate`)
* use this to refresh per-endpoint key state so a key set in Provider Keys is
* reflected on the next chat-send without a stale-cache rejection.
*/
val keyInvalidations: SharedFlow<KeyInvalidation>

/**
* Fetches the per-provider key expiry and maps it to the canonical [KeyState]
* (Unset / Set / Expired) using `now = Clock.System.now()`. Single source of
* truth for the chat-side and settings-side key-status fan-outs so callers
* don't re-implement the wire-string mapping.
*
* Errors from the underlying GET propagate as [Result.Error]; callers decide
* whether to fail open or closed. A null/empty wire string maps to
* [KeyState.Unset] inside [Result.Success].
*/
suspend fun fetchKeyState(name: String): Result<KeyState>
suspend fun updateKey(request: UpdateKeyRequest): Result<Unit>
suspend fun deleteKey(name: String): Result<Unit>
suspend fun deleteAllKeys(): Result<Unit>
}
Loading
Loading