diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandler.kt index d70824b..6faa30d 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandler.kt @@ -4,6 +4,7 @@ import com.jorisjonkers.personalstack.agents.domain.model.GithubLink import com.jorisjonkers.personalstack.agents.domain.port.GithubLinkRepository import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.NotFoundException import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -22,7 +23,7 @@ class AddGithubLinkCommandHandler( override fun handle(command: AddGithubLinkCommand) { val project = projects.findById(command.projectId) - ?: error("project not found: ${command.projectId}") + ?: throw NotFoundException("Project", command.projectId.value.toString()) require(command.name.isNotBlank()) { "link name must not be blank" } require(command.repoUrl.isNotBlank()) { "repo URL must not be blank" } val now = Instant.now() diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandler.kt index 81c9a12..3646d1c 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandler.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agents.application.command import com.jorisjonkers.personalstack.agents.domain.model.Project import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.DomainException import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -19,7 +20,10 @@ class CreateProjectCommandHandler( } val existing = projects.findBySlug(command.slug) if (existing != null && existing.id != command.projectId) { - error("slug already in use: ${command.slug}") + // DomainException, not error(…): the caller picked this slug and + // needs to read which one clashed. `error(…)` throws + // IllegalStateException, whose message the advice withholds. + throw DomainException("Slug already in use: ${command.slug}", "SLUG_ALREADY_IN_USE") } val now = Instant.now() projects.save( diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandler.kt index d1bfee7..d5f6eb2 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandler.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agents.application.command import com.jorisjonkers.personalstack.agents.domain.model.Repository import com.jorisjonkers.personalstack.agents.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.DomainException import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -25,7 +26,13 @@ class CreateRepositoryCommandHandler( // a migration). Dedup on the URL instead. val existing = repositories.findByRepoUrl(command.repoUrl.trim()) if (existing != null && existing.id != command.repositoryId) { - error("repository url already registered: ${command.repoUrl.trim()}") + // DomainException, not error(…): the caller supplied this URL and + // needs to read it back. `error(…)` throws IllegalStateException, + // whose message the advice withholds. + throw DomainException( + "Repository URL already registered: ${command.repoUrl.trim()}", + "REPOSITORY_ALREADY_REGISTERED", + ) } val now = Instant.now() repositories.save( diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandler.kt index dc83437..4fd7abb 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandler.kt @@ -4,6 +4,7 @@ import com.jorisjonkers.personalstack.agents.domain.port.ProjectRepositoryReposi import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository import com.jorisjonkers.personalstack.agents.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.NotFoundException import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional @@ -16,9 +17,9 @@ class LinkRepositoryToProjectCommandHandler( @Transactional override fun handle(command: LinkRepositoryToProjectCommand) { projects.findById(command.projectId) - ?: error("project not found: ${command.projectId}") + ?: throw NotFoundException("Project", command.projectId.value.toString()) repositories.findById(command.repositoryId) - ?: error("repository not found: ${command.repositoryId}") + ?: throw NotFoundException("Repository", command.repositoryId.value.toString()) junction.link(command.projectId, command.repositoryId) } } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandler.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandler.kt index 67ee783..c40bb51 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandler.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandler.kt @@ -5,6 +5,7 @@ import com.jorisjonkers.personalstack.agents.domain.model.Workspace import com.jorisjonkers.personalstack.agents.domain.port.AgentGatewayClient import com.jorisjonkers.personalstack.agents.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.common.command.CommandHandler +import com.jorisjonkers.personalstack.common.exception.NotFoundException import org.springframework.stereotype.Component /** @@ -20,7 +21,9 @@ class OpenPullRequestCommandHandler( private val gateway: AgentGatewayClient, ) : CommandHandler { override fun handle(command: OpenPullRequestCommand) { - val workspace = workspaces.findById(command.workspaceId) ?: error("workspace not found: ${command.workspaceId}") + val workspace = + workspaces.findById(command.workspaceId) + ?: throw NotFoundException("Workspace", command.workspaceId.value.toString()) require(!workspace.hasRunnerSetupGuard()) { "workspace runner setup operation is in progress: ${workspace.id.value}" } diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt index 01da1f9..d6be9a3 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionController.kt @@ -25,6 +25,7 @@ import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.StagedInputR import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.StartAgentSessionRequest import com.jorisjonkers.personalstack.agents.infrastructure.web.dto.TurnResponse import com.jorisjonkers.personalstack.common.command.CommandBus +import com.jorisjonkers.personalstack.common.exception.NotFoundException import com.jorisjonkers.personalstack.common.web.ProblemDetail import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema @@ -211,11 +212,10 @@ class AgentSessionController( @RequestBody req: StageInputRequest, ): ResponseEntity { val workspaceModelId = WorkspaceId(workspaceId) - val session = - sessions.findById(WorkspaceAgentSessionId(sessionId)) - ?: error("session not found: $sessionId") - require(session.workspaceId == workspaceModelId) { "session does not belong to workspace: $sessionId" } - val workspace = workspaces.findById(workspaceModelId) ?: error("workspace not found: $workspaceId") + val session = requireSessionInWorkspace(workspaceId, sessionId) + val workspace = + workspaces.findById(workspaceModelId) + ?: throw NotFoundException("Workspace", workspaceId.toString()) val gatewayAgentId = session.gatewayAgentId ?: throw AgentRunnerUnavailableException( @@ -254,7 +254,7 @@ class AgentSessionController( val workspaceModelId = WorkspaceId(workspaceId) val session = sessions.findById(WorkspaceAgentSessionId(sessionId)) - ?: error("session not found: $sessionId") + ?: throw NotFoundException("AgentSession", sessionId.toString()) require(session.workspaceId == workspaceModelId) { "session does not belong to workspace: $sessionId" } return session } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandlerTest.kt index 67af759..a82e833 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/AddGithubLinkCommandHandlerTest.kt @@ -6,6 +6,7 @@ import com.jorisjonkers.personalstack.agents.domain.model.Project import com.jorisjonkers.personalstack.agents.domain.model.ProjectId import com.jorisjonkers.personalstack.agents.domain.port.GithubLinkRepository import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository +import com.jorisjonkers.personalstack.common.exception.NotFoundException import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -56,11 +57,15 @@ class AddGithubLinkCommandHandlerTest { fun `handle errors on unknown project`() { val missing = ProjectId.random() every { projects.findById(missing) } returns null - assertThrows { - handler.handle( - AddGithubLinkCommand(GithubLinkId.random(), missing, "x", "git@github.com:o/r.git"), - ) - } + + val ex = + assertThrows { + handler.handle( + AddGithubLinkCommand(GithubLinkId.random(), missing, "x", "git@github.com:o/r.git"), + ) + } + + assertThat(ex.message).contains("Project", missing.value.toString()) } @Test diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandlerTest.kt index fc2eadb..c084eb7 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateProjectCommandHandlerTest.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agents.application.command import com.jorisjonkers.personalstack.agents.domain.model.Project import com.jorisjonkers.personalstack.agents.domain.model.ProjectId import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository +import com.jorisjonkers.personalstack.common.exception.DomainException import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -65,8 +66,14 @@ class CreateProjectCommandHandlerTest { updatedAt = Instant.now(), ) every { projects.findBySlug("shared") } returns existing - assertThrows { - handler.handle(CreateProjectCommand(ProjectId.random(), "Mine", "shared")) - } + // DomainException, not IllegalStateException: the caller picked the + // slug, so the advice has to be allowed to hand the message back. + val ex = + assertThrows { + handler.handle(CreateProjectCommand(ProjectId.random(), "Mine", "shared")) + } + + assertThat(ex.code).isEqualTo("SLUG_ALREADY_IN_USE") + assertThat(ex.message).contains("shared") } } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandlerTest.kt index deab931..88d2936 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/CreateRepositoryCommandHandlerTest.kt @@ -3,6 +3,7 @@ package com.jorisjonkers.personalstack.agents.application.command import com.jorisjonkers.personalstack.agents.domain.model.Repository import com.jorisjonkers.personalstack.agents.domain.model.RepositoryId import com.jorisjonkers.personalstack.agents.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.common.exception.DomainException import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -89,10 +90,16 @@ class CreateRepositoryCommandHandlerTest { updatedAt = Instant.now(), ) every { repositories.findByRepoUrl("git@github.com:owner/repo.git") } returns existing - assertThrows { - handler.handle( - CreateRepositoryCommand(RepositoryId.random(), "agents", "git@github.com:owner/repo.git"), - ) - } + // DomainException, not IllegalStateException: the caller supplied the + // URL, so the advice has to be allowed to hand the message back. + val ex = + assertThrows { + handler.handle( + CreateRepositoryCommand(RepositoryId.random(), "agents", "git@github.com:owner/repo.git"), + ) + } + + assertThat(ex.code).isEqualTo("REPOSITORY_ALREADY_REGISTERED") + assertThat(ex.message).contains("git@github.com:owner/repo.git") } } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandlerTest.kt index 8606e19..e39a079 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/LinkRepositoryToProjectCommandHandlerTest.kt @@ -7,9 +7,11 @@ import com.jorisjonkers.personalstack.agents.domain.model.RepositoryId import com.jorisjonkers.personalstack.agents.domain.port.ProjectRepositoryRepository import com.jorisjonkers.personalstack.agents.domain.port.ProjectsRepository import com.jorisjonkers.personalstack.agents.domain.port.RepositoryRepository +import com.jorisjonkers.personalstack.common.exception.NotFoundException import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.time.Instant @@ -50,19 +52,29 @@ class LinkRepositoryToProjectCommandHandlerTest { @Test fun `handle errors on missing project`() { val r = repository() + val missing = ProjectId.random() every { projects.findById(any()) } returns null - assertThrows { - handler.handle(LinkRepositoryToProjectCommand(ProjectId.random(), r.id)) - } + + val ex = + assertThrows { + handler.handle(LinkRepositoryToProjectCommand(missing, r.id)) + } + + assertThat(ex.message).contains("Project", missing.value.toString()) } @Test fun `handle errors on missing repository`() { val p = project() + val missing = RepositoryId.random() every { projects.findById(p.id) } returns p every { repositories.findById(any()) } returns null - assertThrows { - handler.handle(LinkRepositoryToProjectCommand(p.id, RepositoryId.random())) - } + + val ex = + assertThrows { + handler.handle(LinkRepositoryToProjectCommand(p.id, missing)) + } + + assertThat(ex.message).contains("Repository", missing.value.toString()) } } diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandlerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandlerTest.kt index e56c671..17f5a24 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandlerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/application/command/OpenPullRequestCommandHandlerTest.kt @@ -7,9 +7,11 @@ import com.jorisjonkers.personalstack.agents.domain.model.WorkspaceId import com.jorisjonkers.personalstack.agents.domain.model.WorkspaceStatus import com.jorisjonkers.personalstack.agents.domain.port.AgentGatewayClient import com.jorisjonkers.personalstack.agents.domain.port.WorkspaceRepository +import com.jorisjonkers.personalstack.common.exception.NotFoundException import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.time.Instant @@ -41,9 +43,13 @@ class OpenPullRequestCommandHandlerTest { fun `handle errors for unknown workspaces`() { val id = WorkspaceId.random() every { workspaces.findById(id) } returns null - assertThrows { - handler.handle(OpenPullRequestCommand(id, "/x", "t", "b")) - } + + val ex = + assertThrows { + handler.handle(OpenPullRequestCommand(id, "/x", "t", "b")) + } + + assertThat(ex.message).contains("Workspace", id.value.toString()) } @Test diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionControllerTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionControllerTest.kt index 00a7a7c..4108ccd 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionControllerTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/agents/infrastructure/web/AgentSessionControllerTest.kt @@ -314,6 +314,35 @@ class AgentSessionControllerTest { ).andExpect(status().isServiceUnavailable) } + @Test + fun `unknown session is a 404 that still names what was missing`() { + // Was error(…) → a 409 whose message the advice now withholds. As a + // NotFoundException it is both the right status and a detail the + // caller can act on. + val mvc = + MockMvcBuilders + .standaloneSetup( + AgentSessionController( + commandBus, + turnHistory, + sessions, + workspaces, + gateway, + restartAgentSession, + ), + ).setControllerAdvice(GlobalExceptionHandler(), AgentRunnerUnavailableExceptionHandler()) + .build() + every { sessions.findById(sessionId) } returns null + + mvc + .perform( + post("/api/v1/workspaces/${workspaceId.value}/sessions/${sessionId.value}/staged-inputs") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"content":"x","name":"n"}"""), + ).andExpect(status().isNotFound) + .andExpect(jsonPath("$.detail").value("AgentSession not found: ${sessionId.value}")) + } + private fun agentSession() = WorkspaceAgentSession( id = sessionId,