diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/di/AppModule.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/di/AppModule.kt index 177775c677d..dd678adcb24 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/di/AppModule.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/di/AppModule.kt @@ -107,10 +107,10 @@ val appModule = module { single { SqlDelightProjectRepository(get(), get()) } single { SqlDelightServerRepository(get(), get()) } single { SqlDelightMessageRepository(get(), get()) } - single { DefaultSynchronizationService(get(), get(), get(), get()) } + single { DefaultSynchronizationService(get(), get(), get()) } single { DefaultServerService(get(), get(), get()) } - single { DefaultProjectService(get()) } - single { DefaultSessionService(get()) } + single { DefaultProjectService(get(), get()) } + single { DefaultSessionService(get(), get()) } single { DefaultMessageService(get()) } single { get() } single { get() } diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/SynchronizationService.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/SynchronizationService.kt index 8846d2cd01f..c2f3f0eec56 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/SynchronizationService.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/SynchronizationService.kt @@ -1,10 +1,8 @@ package de.chennemann.opencode.mobile.domain.v2 -import de.chennemann.opencode.mobile.domain.v2.projects.ProjectRepository -import de.chennemann.opencode.mobile.domain.v2.projects.LocalProjectInfo +import de.chennemann.opencode.mobile.domain.v2.projects.ProjectService import de.chennemann.opencode.mobile.domain.v2.servers.ServerRepository -import de.chennemann.opencode.mobile.domain.v2.session.LocalSessionRecord -import de.chennemann.opencode.mobile.domain.v2.session.SessionRepository +import de.chennemann.opencode.mobile.domain.v2.session.SessionService interface SynchronizationService { suspend fun syncServer(serverId: String) @@ -12,9 +10,8 @@ interface SynchronizationService { class DefaultSynchronizationService( private val serverRepository: ServerRepository, - private val projectRepository: ProjectRepository, - private val sessionRepository: SessionRepository, - private val adapter: OpenCodeServerAdapter, + private val projectService: ProjectService, + private val sessionService: SessionService, ) : SynchronizationService { override suspend fun syncServer(serverId: String) { val id = serverId.trim() @@ -23,64 +20,13 @@ class DefaultSynchronizationService( val url = server.url.trim() if (url.isBlank()) return - syncProjectsForServer(id, url) - } - - private suspend fun syncProjectsForServer(serverId: String, url: String) { - val remoteProjects = adapter.allProjects(url) - remoteProjects.forEach { remote -> - val projectId = remote.id.trim() - val projectPath = remote.worktree.trim() - if (projectId.isBlank() || projectPath.isBlank()) return@forEach - - val existing = projectRepository.selectProject(projectId) - val local = LocalProjectInfo( - id = projectId, - serverId = serverId, - name = remote.name.trim().ifBlank { projectPath }, - path = projectPath, - pinned = existing?.pinned ?: false, + val projects = projectService.syncServerProjects(id, url) + projects.forEach { project -> + sessionService.syncSessionsOfProject( + projectId = project.id, + projectPath = project.path, + baseUrl = url, ) - - if (existing == null) { - projectRepository.insertProject(local) - } else { - projectRepository.updateProject(local) - } - - syncSessionsForProject(projectId, url) - } - } - - private suspend fun syncSessionsForProject(projectId: String, url: String) { - val id = projectId.trim() - if (id.isBlank()) return - val baseUrl = url.trim() - if (baseUrl.isBlank()) return - val project = projectRepository.selectProject(id) ?: return - val projectPath = project.path.trim() - if (projectPath.isBlank()) return - - val remoteSessions = adapter.allSessionsOfAGivenProject(baseUrl, projectPath) - remoteSessions.forEach { remote -> - val sessionId = remote.id.trim() - val sessionPath = remote.directory.trim() - if (sessionId.isBlank() || sessionPath.isBlank()) return@forEach - - val existing = sessionRepository.selectStoredSession(sessionId) - val local = LocalSessionRecord( - id = sessionId, - projectId = id, - title = remote.title.trim().ifBlank { sessionPath }, - path = sessionPath, - pinned = existing?.pinned ?: false, - ) - - if (existing == null) { - sessionRepository.insertStoredSession(local) - } else { - sessionRepository.updateStoredSession(local) - } } } } diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectService.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectService.kt index 4dc13ea63aa..49e9256d3fc 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectService.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectService.kt @@ -1,15 +1,19 @@ package de.chennemann.opencode.mobile.domain.v2.projects +import de.chennemann.opencode.mobile.domain.v2.OpenCodeServerAdapter import kotlinx.coroutines.flow.Flow interface ProjectService { fun observeProjects(serverId: String? = null): Flow> suspend fun togglePinnedById(projectId: String): Boolean + + suspend fun syncServerProjects(serverId: String, baseUrl: String): List } class DefaultProjectService( private val projectRepository: ProjectRepository, + private val adapter: OpenCodeServerAdapter, ) : ProjectService { override fun observeProjects(serverId: String?): Flow> { return projectRepository.observeProjects(serverId) @@ -24,4 +28,35 @@ class DefaultProjectService( ) return true } + + override suspend fun syncServerProjects(serverId: String, baseUrl: String): List { + val sid = serverId.trim() + val url = baseUrl.trim() + if (sid.isBlank() || url.isBlank()) return emptyList() + + val remoteProjects = adapter.allProjects(url) + val synced = mutableListOf() + remoteProjects.forEach { remote -> + val projectId = remote.id.trim() + val projectPath = remote.worktree.trim() + if (projectId.isBlank() || projectPath.isBlank()) return@forEach + + val existing = projectRepository.selectProject(projectId) + val local = LocalProjectInfo( + id = projectId, + serverId = sid, + name = remote.name.trim().ifBlank { projectPath }, + path = projectPath, + pinned = existing?.pinned ?: false, + ) + + if (existing == null) { + projectRepository.insertProject(local) + } else { + projectRepository.updateProject(local) + } + synced += local + } + return synced + } } diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/session/SessionService.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/session/SessionService.kt index 51ce2eea9bc..d4212a3ac0a 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/session/SessionService.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/session/SessionService.kt @@ -1,15 +1,56 @@ package de.chennemann.opencode.mobile.domain.v2.session +import de.chennemann.opencode.mobile.domain.v2.OpenCodeServerAdapter import kotlinx.coroutines.flow.Flow interface SessionService { fun sessionsOfProject(projectKey: String): Flow> + + suspend fun syncSessionsOfProject( + projectId: String, + projectPath: String, + baseUrl: String, + ) } class DefaultSessionService( private val sessionRepository: SessionRepository, + private val adapter: OpenCodeServerAdapter, ) : SessionService { override fun sessionsOfProject(projectKey: String): Flow> { return sessionRepository.sessionsOfProject(projectKey) } + + override suspend fun syncSessionsOfProject( + projectId: String, + projectPath: String, + baseUrl: String, + ) { + val pid = projectId.trim() + val path = projectPath.trim() + val url = baseUrl.trim() + if (pid.isBlank() || path.isBlank() || url.isBlank()) return + + val remoteSessions = adapter.allSessionsOfAGivenProject(url, path) + remoteSessions.forEach { remote -> + val sessionId = remote.id.trim() + val sessionPath = remote.directory.trim() + if (sessionId.isBlank() || sessionPath.isBlank()) return@forEach + + val existing = sessionRepository.selectStoredSession(sessionId) + val local = LocalSessionRecord( + id = sessionId, + projectId = pid, + title = remote.title.trim().ifBlank { sessionPath }, + path = sessionPath, + pinned = existing?.pinned ?: false, + ) + + if (existing == null) { + sessionRepository.insertStoredSession(local) + } else { + sessionRepository.updateStoredSession(local) + } + } + } } diff --git a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/SynchronizationServiceTest.kt b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/SynchronizationServiceTest.kt index 7e844a128ee..7f9ca776b5a 100644 --- a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/SynchronizationServiceTest.kt +++ b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/SynchronizationServiceTest.kt @@ -1,110 +1,28 @@ package de.chennemann.opencode.mobile.domain.v2 import de.chennemann.opencode.mobile.domain.v2.projects.LocalProjectInfo -import de.chennemann.opencode.mobile.domain.v2.projects.ProjectRepository +import de.chennemann.opencode.mobile.domain.v2.projects.ProjectService import de.chennemann.opencode.mobile.domain.v2.servers.LocalServerInfo import de.chennemann.opencode.mobile.domain.v2.servers.ServerRepository import de.chennemann.opencode.mobile.domain.v2.session.LocalSessionInfo -import de.chennemann.opencode.mobile.domain.v2.session.LocalSessionRecord -import de.chennemann.opencode.mobile.domain.v2.session.SessionRepository +import de.chennemann.opencode.mobile.domain.v2.session.SessionService import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class SynchronizationServiceTest { @Test - fun syncServerLoadsProjectsAndSessionsAndPersistsValidRows() = runTest { - val serverRepository = FakeServerRepository() - val projectRepository = FakeProjectRepository() - val sessionRepository = FakeSessionRepository() - val adapter = FakeOpenCodeServerAdapter() - val service = DefaultSynchronizationService(serverRepository, projectRepository, sessionRepository, adapter) - - serverRepository.insertServer( - LocalServerInfo( - id = "server-1", - url = " https://example.test ", - ) - ) - adapter.projects = listOf( - OpenCodeProject( - id = "p1", - worktree = "/repo/a", - name = "Alpha", - sandboxes = emptyList(), - ), - OpenCodeProject( - id = "p2", - worktree = "/repo/b", - name = " ", - sandboxes = emptyList(), - ), - OpenCodeProject( - id = " ", - worktree = "/repo/c", - name = "Invalid", - sandboxes = emptyList(), - ), - OpenCodeProject( - id = "p3", - worktree = " ", - name = "Invalid", - sandboxes = emptyList(), - ), - ) - adapter.sessionsByPath = mapOf( - "/repo/a" to listOf( - OpenCodeSession( - id = "s1", - projectId = "p1", - directory = "/repo/a", - title = "Session A", - version = "1", - ), - OpenCodeSession( - id = " ", - projectId = "p1", - directory = "/repo/a", - title = "Invalid", - version = "1", - ), - ), - "/repo/b" to listOf( - OpenCodeSession( - id = "s2", - projectId = "p2", - directory = "/repo/b", - title = " ", - version = "1", - ), - OpenCodeSession( - id = "s3", - projectId = "p2", - directory = " ", - title = "Invalid", - version = "1", - ), - ), - ) - - service.syncServer(" server-1 ") - - assertEquals("https://example.test", adapter.lastProjectsBaseUrl) - assertEquals( - listOf( - "https://example.test|/repo/a", - "https://example.test|/repo/b", - ), - adapter.sessionRequests, - ) - assertEquals(2, projectRepository.projects.size) - assertEquals(2, projectRepository.insertCalls) - assertEquals(0, projectRepository.updateCalls) - assertEquals( + fun syncServerDelegatesToProjectAndSessionServices() = runTest { + val servers = FakeServerRepository() + val projects = FakeProjectService() + val sessions = FakeSessionService() + val service = DefaultSynchronizationService(servers, projects, sessions) + + servers.insertServer(LocalServerInfo(id = "server-1", url = " https://example.test ")) + projects.nextProjects = listOf( LocalProjectInfo( id = "p1", serverId = "server-1", @@ -112,118 +30,42 @@ class SynchronizationServiceTest { path = "/repo/a", pinned = false, ), - projectRepository.projects["p1"], - ) - assertEquals( LocalProjectInfo( id = "p2", serverId = "server-1", - name = "/repo/b", - path = "/repo/b", - pinned = false, - ), - projectRepository.projects["p2"], - ) - assertEquals(2, sessionRepository.sessions.size) - assertEquals(2, sessionRepository.insertCalls) - assertEquals(0, sessionRepository.updateCalls) - assertEquals( - LocalSessionRecord( - id = "s1", - projectId = "p1", - title = "Session A", - path = "/repo/a", - pinned = false, - ), - sessionRepository.sessions["s1"], - ) - assertEquals( - LocalSessionRecord( - id = "s2", - projectId = "p2", - title = "/repo/b", + name = "Beta", path = "/repo/b", - pinned = false, - ), - sessionRepository.sessions["s2"], - ) - } - - @Test - fun syncServerUpdatesExistingSessionAndKeepsPinnedState() = runTest { - val serverRepository = FakeServerRepository() - val projectRepository = FakeProjectRepository() - val sessionRepository = FakeSessionRepository() - val adapter = FakeOpenCodeServerAdapter() - val service = DefaultSynchronizationService(serverRepository, projectRepository, sessionRepository, adapter) - - serverRepository.insertServer(LocalServerInfo(id = "server-1", url = "https://example.test")) - projectRepository.projects["p1"] = LocalProjectInfo( - id = "p1", - serverId = "server-1", - name = "Old Name", - path = "/repo/old", - pinned = true, - ) - sessionRepository.sessions["s1"] = LocalSessionRecord( - id = "s1", - projectId = "p1", - title = "Old Session", - path = "/repo/old", - pinned = true, - ) - adapter.projects = listOf( - OpenCodeProject( - id = "p1", - worktree = "/repo/new", - name = "New Name", - sandboxes = emptyList(), - ), - ) - adapter.sessionsByPath = mapOf( - "/repo/new" to listOf( - OpenCodeSession( - id = "s1", - projectId = "p1", - directory = "/repo/new", - title = "New Session", - version = "2", - ), + pinned = true, ), ) - service.syncServer("server-1") + service.syncServer(" server-1 ") - assertEquals(0, projectRepository.insertCalls) - assertEquals(1, projectRepository.updateCalls) - assertEquals(0, sessionRepository.insertCalls) - assertEquals(1, sessionRepository.updateCalls) + assertEquals(listOf("server-1|https://example.test"), projects.syncCalls) assertEquals( - LocalSessionRecord( - id = "s1", - projectId = "p1", - title = "New Session", - path = "/repo/new", - pinned = true, + listOf( + "p1|/repo/a|https://example.test", + "p2|/repo/b|https://example.test", ), - sessionRepository.sessions["s1"], + sessions.syncCalls, ) } @Test - fun syncServerSkipsWhenServerDoesNotExist() = runTest { - val serverRepository = FakeServerRepository() - val projectRepository = FakeProjectRepository() - val sessionRepository = FakeSessionRepository() - val adapter = FakeOpenCodeServerAdapter() - val service = DefaultSynchronizationService(serverRepository, projectRepository, sessionRepository, adapter) + fun syncServerSkipsWhenIdBlankOrServerMissingOrUrlBlank() = runTest { + val servers = FakeServerRepository() + val projects = FakeProjectService() + val sessions = FakeSessionService() + val service = DefaultSynchronizationService(servers, projects, sessions) + + servers.insertServer(LocalServerInfo(id = "server-blank-url", url = " ")) + service.syncServer(" ") service.syncServer("missing") + service.syncServer("server-blank-url") - assertNull(adapter.lastProjectsBaseUrl) - assertTrue(adapter.sessionRequests.isEmpty()) - assertTrue(projectRepository.projects.isEmpty()) - assertTrue(sessionRepository.sessions.isEmpty()) + assertTrue(projects.syncCalls.isEmpty()) + assertTrue(sessions.syncCalls.isEmpty()) } } @@ -255,95 +97,30 @@ private class FakeServerRepository : ServerRepository { } } -private class FakeProjectRepository : ProjectRepository { - val projects = linkedMapOf() - var insertCalls: Int = 0 - var updateCalls: Int = 0 +private class FakeProjectService : ProjectService { + val syncCalls = mutableListOf() + var nextProjects: List = emptyList() override fun observeProjects(serverId: String?): Flow> { - val id = serverId?.trim()?.ifBlank { null } - val values = if (id == null) { - projects.values.toList() - } else { - projects.values.filter { it.serverId == id } - } - return flowOf(values) - } - - override suspend fun selectProject(id: String): LocalProjectInfo? { - return projects[id] - } - - override suspend fun insertProject(project: LocalProjectInfo) { - insertCalls += 1 - projects[project.id] = project - } - - override suspend fun updateProject(project: LocalProjectInfo) { - updateCalls += 1 - projects[project.id] = project - } - - override suspend fun deleteProject(id: String) { - projects.remove(id) - } -} - -private class FakeSessionRepository : SessionRepository { - val sessions = linkedMapOf() - var insertCalls: Int = 0 - var updateCalls: Int = 0 - - override fun sessionsOfProject(projectKey: String): Flow> { return flowOf(emptyList()) } - override fun observeStoredSessions(projectId: String?): Flow> { - val id = projectId?.trim()?.ifBlank { null } - val values = if (id == null) { - sessions.values.toList() - } else { - sessions.values.filter { it.projectId == id } - } - return flowOf(values) + override suspend fun togglePinnedById(projectId: String): Boolean { + return true } - override suspend fun selectStoredSession(id: String): LocalSessionRecord? { - return sessions[id] - } - - override suspend fun insertStoredSession(session: LocalSessionRecord) { - insertCalls += 1 - sessions[session.id] = session - } - - override suspend fun updateStoredSession(session: LocalSessionRecord) { - updateCalls += 1 - sessions[session.id] = session - } - - override suspend fun deleteStoredSession(id: String) { - sessions.remove(id) + override suspend fun syncServerProjects(serverId: String, baseUrl: String): List { + syncCalls += "$serverId|$baseUrl" + return nextProjects } } -private class FakeOpenCodeServerAdapter : OpenCodeServerAdapter { - var lastProjectsBaseUrl: String? = null - var projects: List = emptyList() - var sessionsByPath: Map> = emptyMap() - val sessionRequests = mutableListOf() - - override suspend fun healthCheckWithUrl(baseUrl: String): OpenCodeHealthCheck { - return OpenCodeHealthCheck(healthy = true, version = "1.0.0") - } +private class FakeSessionService : SessionService { + val syncCalls = mutableListOf() - override suspend fun allProjects(baseUrl: String): List { - lastProjectsBaseUrl = baseUrl - return projects - } + override fun sessionsOfProject(projectKey: String) = flowOf(emptyList()) - override suspend fun allSessionsOfAGivenProject(baseUrl: String, path: String): List { - sessionRequests += "$baseUrl|$path" - return sessionsByPath[path].orEmpty() + override suspend fun syncSessionsOfProject(projectId: String, projectPath: String, baseUrl: String) { + syncCalls += "$projectId|$projectPath|$baseUrl" } } diff --git a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectServiceTest.kt b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectServiceTest.kt index bf3278d683a..8b12028f799 100644 --- a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectServiceTest.kt +++ b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectServiceTest.kt @@ -1,5 +1,9 @@ package de.chennemann.opencode.mobile.domain.v2.projects +import de.chennemann.opencode.mobile.domain.v2.OpenCodeHealthCheck +import de.chennemann.opencode.mobile.domain.v2.OpenCodeProject +import de.chennemann.opencode.mobile.domain.v2.OpenCodeServerAdapter +import de.chennemann.opencode.mobile.domain.v2.OpenCodeSession import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -13,7 +17,8 @@ class ProjectServiceTest { @Test fun observeProjectsDelegatesToRepository() { val repository = FakeProjectRepository() - val service = DefaultProjectService(repository) + val adapter = FakeOpenCodeServerAdapter() + val service = DefaultProjectService(repository, adapter) val observed = service.observeProjects("server-1") @@ -34,7 +39,8 @@ class ProjectServiceTest { ) ) ) - val service = DefaultProjectService(repository) + val adapter = FakeOpenCodeServerAdapter() + val service = DefaultProjectService(repository, adapter) val toggled = service.togglePinnedById(" p1 ") @@ -46,12 +52,84 @@ class ProjectServiceTest { @Test fun togglePinnedByIdReturnsFalseForMissingOrBlankId() = runTest { val repository = FakeProjectRepository() - val service = DefaultProjectService(repository) + val adapter = FakeOpenCodeServerAdapter() + val service = DefaultProjectService(repository, adapter) assertFalse(service.togglePinnedById(" ")) assertFalse(service.togglePinnedById("missing")) assertTrue(repository.updateCalls.isEmpty()) } + + @Test + fun syncServerProjectsPersistsValidRowsAndReturnsSyncedProjects() = runTest { + val repository = FakeProjectRepository() + val adapter = FakeOpenCodeServerAdapter().apply { + projects = listOf( + OpenCodeProject(id = "p1", worktree = "/repo/a", name = "Alpha", sandboxes = emptyList()), + OpenCodeProject(id = "p2", worktree = "/repo/b", name = " ", sandboxes = emptyList()), + OpenCodeProject(id = " ", worktree = "/repo/c", name = "Invalid", sandboxes = emptyList()), + OpenCodeProject(id = "p3", worktree = " ", name = "Invalid", sandboxes = emptyList()), + ) + } + val service = DefaultProjectService(repository, adapter) + + val synced = service.syncServerProjects(" server-1 ", " https://example.test ") + + assertEquals(listOf("https://example.test"), adapter.projectRequests) + assertEquals(listOf("p1", "p2"), synced.map { it.id }) + assertEquals(2, repository.insertCalls) + assertEquals(0, repository.updateCalls.size) + assertEquals( + LocalProjectInfo( + id = "p1", + serverId = "server-1", + name = "Alpha", + path = "/repo/a", + pinned = false, + ), + repository.projects["p1"], + ) + assertEquals( + LocalProjectInfo( + id = "p2", + serverId = "server-1", + name = "/repo/b", + path = "/repo/b", + pinned = false, + ), + repository.projects["p2"], + ) + } + + @Test + fun syncServerProjectsKeepsPinnedStateWhenUpdating() = runTest { + val repository = FakeProjectRepository( + initial = linkedMapOf( + "p1" to LocalProjectInfo( + id = "p1", + serverId = "server-1", + name = "Old", + path = "/repo/old", + pinned = true, + ) + ) + ) + val adapter = FakeOpenCodeServerAdapter().apply { + projects = listOf( + OpenCodeProject(id = "p1", worktree = "/repo/new", name = "New", sandboxes = emptyList()), + ) + } + val service = DefaultProjectService(repository, adapter) + + val synced = service.syncServerProjects("server-1", "https://example.test") + + assertEquals(listOf("p1"), synced.map { it.id }) + assertEquals(0, repository.insertCalls) + assertEquals(1, repository.updateCalls.size) + assertEquals(true, repository.projects["p1"]?.pinned) + assertEquals("/repo/new", repository.projects["p1"]?.path) + assertEquals("New", repository.projects["p1"]?.name) + } } private class FakeProjectRepository( @@ -60,6 +138,7 @@ private class FakeProjectRepository( val projects = initial val flow = flowOf(projects.values.toList()) val updateCalls = mutableListOf() + var insertCalls: Int = 0 var lastObservedServerId: String? = null override fun observeProjects(serverId: String?): Flow> { @@ -72,6 +151,7 @@ private class FakeProjectRepository( } override suspend fun insertProject(project: LocalProjectInfo) { + insertCalls += 1 projects[project.id] = project } @@ -84,3 +164,21 @@ private class FakeProjectRepository( projects.remove(id) } } + +private class FakeOpenCodeServerAdapter : OpenCodeServerAdapter { + var projects: List = emptyList() + val projectRequests = mutableListOf() + + override suspend fun healthCheckWithUrl(baseUrl: String): OpenCodeHealthCheck { + return OpenCodeHealthCheck(healthy = true, version = "1.0.0") + } + + override suspend fun allProjects(baseUrl: String): List { + projectRequests += baseUrl + return projects + } + + override suspend fun allSessionsOfAGivenProject(baseUrl: String, path: String): List { + return emptyList() + } +} diff --git a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/session/SessionServiceTest.kt b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/session/SessionServiceTest.kt new file mode 100644 index 00000000000..63473d7d3ec --- /dev/null +++ b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/session/SessionServiceTest.kt @@ -0,0 +1,192 @@ +package de.chennemann.opencode.mobile.domain.v2.session + +import de.chennemann.opencode.mobile.domain.v2.OpenCodeHealthCheck +import de.chennemann.opencode.mobile.domain.v2.OpenCodeProject +import de.chennemann.opencode.mobile.domain.v2.OpenCodeServerAdapter +import de.chennemann.opencode.mobile.domain.v2.OpenCodeSession +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SessionServiceTest { + @Test + fun sessionsOfProjectDelegatesToRepository() { + val repository = FakeSessionRepository() + val adapter = FakeOpenCodeServerAdapter() + val service = DefaultSessionService(repository, adapter) + + val observed = service.sessionsOfProject("project-1") + + assertSame(repository.projectSessionsFlow, observed) + assertEquals("project-1", repository.lastProjectKey) + } + + @Test + fun syncSessionsOfProjectPersistsValidRows() = runTest { + val repository = FakeSessionRepository() + val adapter = FakeOpenCodeServerAdapter().apply { + sessionsByPath["/repo/a"] = listOf( + OpenCodeSession( + id = "s1", + projectId = "p1", + directory = "/repo/a", + title = "Session A", + version = "1", + ), + OpenCodeSession( + id = "s2", + projectId = "p1", + directory = "/repo/a", + title = " ", + version = "1", + ), + OpenCodeSession( + id = " ", + projectId = "p1", + directory = "/repo/a", + title = "Invalid", + version = "1", + ), + OpenCodeSession( + id = "s3", + projectId = "p1", + directory = " ", + title = "Invalid", + version = "1", + ), + ) + } + val service = DefaultSessionService(repository, adapter) + + service.syncSessionsOfProject(" p1 ", " /repo/a ", " https://example.test ") + + assertEquals(listOf("https://example.test|/repo/a"), adapter.sessionRequests) + assertEquals(2, repository.insertCalls) + assertEquals(0, repository.updateCalls.size) + assertEquals( + LocalSessionRecord( + id = "s1", + projectId = "p1", + title = "Session A", + path = "/repo/a", + pinned = false, + ), + repository.stored["s1"], + ) + assertEquals( + LocalSessionRecord( + id = "s2", + projectId = "p1", + title = "/repo/a", + path = "/repo/a", + pinned = false, + ), + repository.stored["s2"], + ) + } + + @Test + fun syncSessionsOfProjectKeepsPinnedOnUpdate() = runTest { + val repository = FakeSessionRepository().apply { + stored["s1"] = LocalSessionRecord( + id = "s1", + projectId = "p1", + title = "Old", + path = "/repo/old", + pinned = true, + ) + } + val adapter = FakeOpenCodeServerAdapter().apply { + sessionsByPath["/repo/new"] = listOf( + OpenCodeSession( + id = "s1", + projectId = "p1", + directory = "/repo/new", + title = "New", + version = "2", + ) + ) + } + val service = DefaultSessionService(repository, adapter) + + service.syncSessionsOfProject("p1", "/repo/new", "https://example.test") + + assertEquals(0, repository.insertCalls) + assertEquals(1, repository.updateCalls.size) + assertEquals(true, repository.stored["s1"]?.pinned) + assertEquals("/repo/new", repository.stored["s1"]?.path) + assertEquals("New", repository.stored["s1"]?.title) + } + + @Test + fun syncSessionsOfProjectSkipsBlankInputs() = runTest { + val repository = FakeSessionRepository() + val adapter = FakeOpenCodeServerAdapter() + val service = DefaultSessionService(repository, adapter) + + service.syncSessionsOfProject(" ", "/repo/a", "https://example.test") + service.syncSessionsOfProject("p1", " ", "https://example.test") + service.syncSessionsOfProject("p1", "/repo/a", " ") + + assertTrue(adapter.sessionRequests.isEmpty()) + assertEquals(0, repository.insertCalls) + assertTrue(repository.updateCalls.isEmpty()) + } +} + +private class FakeSessionRepository : SessionRepository { + val projectSessionsFlow = flowOf(emptyList()) + val stored = linkedMapOf() + var lastProjectKey: String? = null + var insertCalls: Int = 0 + val updateCalls = mutableListOf() + + override fun sessionsOfProject(projectKey: String): Flow> { + lastProjectKey = projectKey + return projectSessionsFlow + } + + override fun observeStoredSessions(projectId: String?): Flow> { + return flowOf(stored.values.toList()) + } + + override suspend fun selectStoredSession(id: String): LocalSessionRecord? { + return stored[id] + } + + override suspend fun insertStoredSession(session: LocalSessionRecord) { + insertCalls += 1 + stored[session.id] = session + } + + override suspend fun updateStoredSession(session: LocalSessionRecord) { + updateCalls += session + stored[session.id] = session + } + + override suspend fun deleteStoredSession(id: String) { + stored.remove(id) + } +} + +private class FakeOpenCodeServerAdapter : OpenCodeServerAdapter { + val sessionsByPath = linkedMapOf>() + val sessionRequests = mutableListOf() + + override suspend fun healthCheckWithUrl(baseUrl: String): OpenCodeHealthCheck { + return OpenCodeHealthCheck(healthy = true, version = "1.0.0") + } + + override suspend fun allProjects(baseUrl: String): List { + return emptyList() + } + + override suspend fun allSessionsOfAGivenProject(baseUrl: String, path: String): List { + sessionRequests += "$baseUrl|$path" + return sessionsByPath[path].orEmpty() + } +} diff --git a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt index 59a3c90ac6f..06ed3577a6d 100644 --- a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt +++ b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt @@ -320,4 +320,8 @@ private class StubProjectService : ProjectServiceV2 { toggleRequests += projectId return true } + + override suspend fun syncServerProjects(serverId: String, baseUrl: String): List { + return emptyList() + } }