diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt index e194b33d..bd05963c 100644 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt +++ b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt @@ -9,6 +9,8 @@ import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository +import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository import com.jorisjonkers.personalstack.assistant.infrastructure.k8s.Fabric8AgentRunnerOrchestrator import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientException @@ -379,12 +381,16 @@ class Fabric8AgentRunnerOrchestratorIntegrationTest { client: KubernetesClient, deployKeysProvider: ObjectProvider, githubLinks: ObjectProvider, + workspaceRepos: ObjectProvider = empty(), + repositories: ObjectProvider = empty(), ): Fabric8AgentRunnerOrchestrator = Fabric8AgentRunnerOrchestrator( client = client, props = testProps(), deployKeysProvider = deployKeysProvider, githubLinks = githubLinks, + workspaceRepos = workspaceRepos, + repositories = repositories, ) private fun testProps(): AgentRuntimeProperties = diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt new file mode 100644 index 00000000..cba63690 --- /dev/null +++ b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt @@ -0,0 +1,97 @@ +package com.jorisjonkers.personalstack.assistant.persistence + +import com.jorisjonkers.personalstack.assistant.IntegrationTestBase +import com.jorisjonkers.personalstack.assistant.domain.model.Repository +import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId +import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus +import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.Instant +import java.util.UUID + +class JooqWorkspaceRepositoryRepositoryIntegrationTest : IntegrationTestBase() { + @Autowired + private lateinit var junction: WorkspaceRepositoryRepository + + @Autowired + private lateinit var workspaces: WorkspaceRepository + + @Autowired + private lateinit var repositories: RepositoryRepository + + private fun workspace() = + Workspace( + id = WorkspaceId.random(), + name = "w", + repoUrl = "git@github.com:o/primary.git", + branch = "main", + podName = null, + pvcName = null, + gatewayEndpoint = null, + status = WorkspaceStatus.PENDING, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ).also(workspaces::save) + + private fun repository() = + Repository( + id = RepositoryId.random(), + name = "r-${UUID.randomUUID()}", + repoUrl = "git@github.com:o/r.git", + defaultBranch = "main", + vaultKeyPath = "x", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ).also(repositories::save) + + @Test + fun `attach inserts a row carried by findAllByWorkspaceId`() { + val w = workspace() + val r = repository() + junction.attach(w.id, r.id, isPrimary = true) + + val links = junction.findAllByWorkspaceId(w.id) + assertThat(links).hasSize(1) + assertThat(links.single().repositoryId).isEqualTo(r.id) + assertThat(links.single().isPrimary).isTrue + } + + @Test + fun `attach is idempotent on duplicate insert`() { + val w = workspace() + val r = repository() + junction.attach(w.id, r.id) + junction.attach(w.id, r.id) + assertThat(junction.findAllByWorkspaceId(w.id)).hasSize(1) + } + + @Test + fun `detach removes the row`() { + val w = workspace() + val r = repository() + junction.attach(w.id, r.id) + junction.detach(w.id, r.id) + assertThat(junction.findAllByWorkspaceId(w.id)).isEmpty() + } + + @Test + fun `findAllByWorkspaceId returns every attached repository`() { + val w = workspace() + val r1 = repository() + val r2 = repository() + junction.attach(w.id, r1.id, isPrimary = true) + junction.attach(w.id, r2.id) + + val links = junction.findAllByWorkspaceId(w.id) + assertThat(links.map { it.repositoryId }).containsExactlyInAnyOrder(r1.id, r2.id) + assertThat(links.filter { it.isPrimary }.map { it.repositoryId }).containsExactly(r1.id) + } +} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt new file mode 100644 index 00000000..7e9c9480 --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt @@ -0,0 +1,15 @@ +package com.jorisjonkers.personalstack.assistant.application.command + +import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.common.command.Command + +/** + * Attach an additional repository to a workspace. The repo is cloned + * alongside the primary (it becomes part of REPO_URLS) the next time the + * runner Pod boots; running pods are not re-provisioned. + */ +data class AttachWorkspaceRepositoryCommand( + val workspaceId: WorkspaceId, + val repositoryId: RepositoryId, +) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt new file mode 100644 index 00000000..834ebd4e --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt @@ -0,0 +1,24 @@ +package com.jorisjonkers.personalstack.assistant.application.command + +import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository +import com.jorisjonkers.personalstack.common.command.CommandHandler +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class AttachWorkspaceRepositoryCommandHandler( + private val workspaces: WorkspaceRepository, + private val repositories: RepositoryRepository, + private val links: WorkspaceRepositoryRepository, +) : CommandHandler { + @Transactional + override fun handle(command: AttachWorkspaceRepositoryCommand) { + workspaces.findById(command.workspaceId) + ?: throw NoSuchElementException("workspace not found: ${command.workspaceId}") + repositories.findById(command.repositoryId) + ?: throw NoSuchElementException("repository not found: ${command.repositoryId}") + links.attach(command.workspaceId, command.repositoryId, isPrimary = false) + } +} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommand.kt new file mode 100644 index 00000000..7aed3406 --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommand.kt @@ -0,0 +1,14 @@ +package com.jorisjonkers.personalstack.assistant.application.command + +import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.common.command.Command + +/** + * Detach an additional repository from a workspace. The primary repository + * cannot be detached this way — it is owned by the workspace row itself. + */ +data class DetachWorkspaceRepositoryCommand( + val workspaceId: WorkspaceId, + val repositoryId: RepositoryId, +) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandler.kt new file mode 100644 index 00000000..82b1b671 --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandler.kt @@ -0,0 +1,23 @@ +package com.jorisjonkers.personalstack.assistant.application.command + +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository +import com.jorisjonkers.personalstack.common.command.CommandHandler +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class DetachWorkspaceRepositoryCommandHandler( + private val links: WorkspaceRepositoryRepository, +) : CommandHandler { + @Transactional + override fun handle(command: DetachWorkspaceRepositoryCommand) { + val link = + links + .findAllByWorkspaceId(command.workspaceId) + .firstOrNull { it.repositoryId == command.repositoryId } + require(link == null || !link.isPrimary) { + "cannot detach the primary repository from a workspace" + } + links.detach(command.workspaceId, command.repositoryId) + } +} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepositoryRepository.kt new file mode 100644 index 00000000..e7826833 --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepositoryRepository.kt @@ -0,0 +1,33 @@ +package com.jorisjonkers.personalstack.assistant.domain.port + +import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import java.time.Instant + +/** + * Junction-table port for the workspace_repositories M:N mapping — the + * repositories a workspace clones beyond its primary. Operations are explicit + * (`attach` / `detach`) rather than save/find because the row carries no + * payload beyond the pair, the primary flag, and attachedAt. + */ +interface WorkspaceRepositoryRepository { + data class Link( + val workspaceId: WorkspaceId, + val repositoryId: RepositoryId, + val isPrimary: Boolean, + val attachedAt: Instant, + ) + + fun attach( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + isPrimary: Boolean = false, + ): Link + + fun detach( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + ) + + fun findAllByWorkspaceId(workspaceId: WorkspaceId): List +} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt index e40e0b88..255f8974 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt @@ -6,6 +6,8 @@ import com.jorisjonkers.personalstack.assistant.domain.model.Workspace import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository +import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository import io.fabric8.kubernetes.api.model.ContainerPortBuilder import io.fabric8.kubernetes.api.model.EnvVarBuilder import io.fabric8.kubernetes.api.model.PersistentVolumeClaim @@ -53,6 +55,8 @@ class Fabric8AgentRunnerOrchestrator( private val props: AgentRuntimeProperties, private val deployKeysProvider: ObjectProvider, private val githubLinks: ObjectProvider, + private val workspaceRepos: ObjectProvider, + private val repositories: ObjectProvider, ) : AgentRunnerOrchestrator { private val log = LoggerFactory.getLogger(Fabric8AgentRunnerOrchestrator::class.java) @@ -377,8 +381,25 @@ class Fabric8AgentRunnerOrchestrator( add(EnvVarBuilder().withName("REPO_URL").withValue(url).build()) workspace.branch?.let { add(EnvVarBuilder().withName("REPO_BRANCH").withValue(it).build()) } } + // REPO_URLS carries the workspace's additional repos (everything + // attached that is not the primary). The entrypoint clones each + // into its own subdir over the App-token credential helper. Only + // emitted when there are extras, so single-repo workspaces look + // identical to before to an older runner image. + additionalRepoUrls(workspace).takeIf { it.isNotEmpty() }?.let { urls -> + add(EnvVarBuilder().withName("REPO_URLS").withValue(urls.joinToString(" ")).build()) + } } + private fun additionalRepoUrls(workspace: Workspace): List { + val links = workspaceRepos.ifAvailable ?: return emptyList() + val repos = repositories.ifAvailable ?: return emptyList() + return links + .findAllByWorkspaceId(workspace.id) + .filterNot { it.isPrimary } + .mapNotNull { repos.findById(it.repositoryId)?.repoUrl } + } + // KB_URL + KB_BEARER_TOKEN are the exact names the knowledge-system // install.sh hooks read; without the bearer every hook short-circuits // to a no-op and the knowledge.* MCP tools are unreachable. diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt new file mode 100644 index 00000000..c026cea1 --- /dev/null +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt @@ -0,0 +1,75 @@ +package com.jorisjonkers.personalstack.assistant.infrastructure.persistence + +import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository +import org.jooq.DSLContext +import org.jooq.Record +import org.jooq.impl.DSL +import org.springframework.stereotype.Repository +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +@Repository +class JooqWorkspaceRepositoryRepository( + private val dsl: DSLContext, +) : WorkspaceRepositoryRepository { + override fun attach( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + isPrimary: Boolean, + ): WorkspaceRepositoryRepository.Link { + val now = Instant.now() + dsl + .insertInto(JUNCTION) + .set(WORKSPACE_ID, workspaceId.value) + .set(REPOSITORY_ID, repositoryId.value) + .set(IS_PRIMARY, isPrimary) + .set(ATTACHED_AT, now.atOffset(ZoneOffset.UTC)) + .onConflict(WORKSPACE_ID, REPOSITORY_ID) + .doNothing() + .execute() + return WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, now) + } + + override fun detach( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + ) { + dsl + .deleteFrom(JUNCTION) + .where(WORKSPACE_ID.eq(workspaceId.value)) + .and(REPOSITORY_ID.eq(repositoryId.value)) + .execute() + } + + override fun findAllByWorkspaceId(workspaceId: WorkspaceId): List = + dsl + .selectFrom(JUNCTION) + .where(WORKSPACE_ID.eq(workspaceId.value)) + .orderBy(ATTACHED_AT.asc()) + .fetch() + .map { it.toLink() } + + private fun Record.toLink(): WorkspaceRepositoryRepository.Link = + WorkspaceRepositoryRepository.Link( + workspaceId = WorkspaceId(this[WORKSPACE_ID]), + repositoryId = RepositoryId(this[REPOSITORY_ID]), + isPrimary = this[IS_PRIMARY], + attachedAt = this[ATTACHED_AT].toInstant(), + ) + + companion object { + @JvmStatic val JUNCTION = DSL.table("workspace_repositories") + + @JvmStatic val WORKSPACE_ID = DSL.field("workspace_id", UUID::class.java) + + @JvmStatic val REPOSITORY_ID = DSL.field("repository_id", UUID::class.java) + + @JvmStatic val IS_PRIMARY = DSL.field("is_primary", Boolean::class.java) + + @JvmStatic val ATTACHED_AT = DSL.field("attached_at", OffsetDateTime::class.java) + } +} diff --git a/services/assistant-api/src/main/resources/db/migration/V13__workspace_repositories.sql b/services/assistant-api/src/main/resources/db/migration/V13__workspace_repositories.sql new file mode 100644 index 00000000..f73eed89 --- /dev/null +++ b/services/assistant-api/src/main/resources/db/migration/V13__workspace_repositories.sql @@ -0,0 +1,21 @@ +-- A workspace can clone more than one repository. The primary repo stays +-- denormalised on workspaces (repository_id / repo_url) and drives REPO_URL; +-- this junction carries the additional repos that become REPO_URLS in the +-- runner Pod. is_primary marks the backfilled primary so the orchestrator can +-- exclude it from REPO_URLS without a second lookup. +CREATE TABLE workspace_repositories ( + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + attached_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (workspace_id, repository_id) +); + +CREATE INDEX idx_workspace_repositories_repository ON workspace_repositories (repository_id); + +-- Backfill the primary repo for existing repo-backed workspaces so the model +-- is consistent from day one. +INSERT INTO workspace_repositories (workspace_id, repository_id, is_primary, attached_at) +SELECT id, repository_id, TRUE, created_at +FROM workspaces +WHERE repository_id IS NOT NULL; diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt new file mode 100644 index 00000000..e581563d --- /dev/null +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt @@ -0,0 +1,52 @@ +package com.jorisjonkers.personalstack.assistant.application.command + +import com.jorisjonkers.personalstack.assistant.domain.model.Repository +import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId +import com.jorisjonkers.personalstack.assistant.domain.model.Workspace +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class AttachWorkspaceRepositoryCommandHandlerTest { + private val workspaces = mockk() + private val repositories = mockk() + private val links = mockk(relaxed = true) + private val handler = AttachWorkspaceRepositoryCommandHandler(workspaces, repositories, links) + + private val workspaceId = WorkspaceId.random() + private val repositoryId = RepositoryId.random() + private val command = AttachWorkspaceRepositoryCommand(workspaceId, repositoryId) + + @Test + fun `attaches a non-primary link when both exist`() { + every { workspaces.findById(workspaceId) } returns mockk() + every { repositories.findById(repositoryId) } returns mockk() + + handler.handle(command) + + verify { links.attach(workspaceId, repositoryId, isPrimary = false) } + } + + @Test + fun `rejects an unknown workspace`() { + every { workspaces.findById(workspaceId) } returns null + + assertThrows { handler.handle(command) } + verify(exactly = 0) { links.attach(any(), any(), any()) } + } + + @Test + fun `rejects an unknown repository`() { + every { workspaces.findById(workspaceId) } returns mockk() + every { repositories.findById(repositoryId) } returns null + + assertThrows { handler.handle(command) } + verify(exactly = 0) { links.attach(any(), any(), any()) } + } +} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandlerTest.kt new file mode 100644 index 00000000..386fad49 --- /dev/null +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandlerTest.kt @@ -0,0 +1,49 @@ +package com.jorisjonkers.personalstack.assistant.application.command + +import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Instant + +class DetachWorkspaceRepositoryCommandHandlerTest { + private val links = mockk(relaxed = true) + private val handler = DetachWorkspaceRepositoryCommandHandler(links) + + private val workspaceId = WorkspaceId.random() + private val repositoryId = RepositoryId.random() + private val command = DetachWorkspaceRepositoryCommand(workspaceId, repositoryId) + + private fun link(isPrimary: Boolean) = + WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, Instant.now()) + + @Test + fun `detaches a non-primary repository`() { + every { links.findAllByWorkspaceId(workspaceId) } returns listOf(link(isPrimary = false)) + + handler.handle(command) + + verify { links.detach(workspaceId, repositoryId) } + } + + @Test + fun `refuses to detach the primary repository`() { + every { links.findAllByWorkspaceId(workspaceId) } returns listOf(link(isPrimary = true)) + + assertThrows { handler.handle(command) } + verify(exactly = 0) { links.detach(any(), any()) } + } + + @Test + fun `is a no-op-safe detach when no link exists`() { + every { links.findAllByWorkspaceId(workspaceId) } returns emptyList() + + handler.handle(command) + + verify { links.detach(workspaceId, repositoryId) } + } +}