Skip to content
Open
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 @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -20,7 +21,9 @@ class OpenPullRequestCommandHandler(
private val gateway: AgentGatewayClient,
) : CommandHandler<OpenPullRequestCommand> {
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}"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,11 +212,10 @@ class AgentSessionController(
@RequestBody req: StageInputRequest,
): ResponseEntity<StagedInputResponse> {
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(
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,11 +57,15 @@ class AddGithubLinkCommandHandlerTest {
fun `handle errors on unknown project`() {
val missing = ProjectId.random()
every { projects.findById(missing) } returns null
assertThrows<IllegalStateException> {
handler.handle(
AddGithubLinkCommand(GithubLinkId.random(), missing, "x", "git@github.com:o/r.git"),
)
}

val ex =
assertThrows<NotFoundException> {
handler.handle(
AddGithubLinkCommand(GithubLinkId.random(), missing, "x", "git@github.com:o/r.git"),
)
}

assertThat(ex.message).contains("Project", missing.value.toString())
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,8 +66,14 @@ class CreateProjectCommandHandlerTest {
updatedAt = Instant.now(),
)
every { projects.findBySlug("shared") } returns existing
assertThrows<IllegalStateException> {
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<DomainException> {
handler.handle(CreateProjectCommand(ProjectId.random(), "Mine", "shared"))
}

assertThat(ex.code).isEqualTo("SLUG_ALREADY_IN_USE")
assertThat(ex.message).contains("shared")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,10 +90,16 @@ class CreateRepositoryCommandHandlerTest {
updatedAt = Instant.now(),
)
every { repositories.findByRepoUrl("git@github.com:owner/repo.git") } returns existing
assertThrows<IllegalStateException> {
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<DomainException> {
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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<IllegalStateException> {
handler.handle(LinkRepositoryToProjectCommand(ProjectId.random(), r.id))
}

val ex =
assertThrows<NotFoundException> {
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<IllegalStateException> {
handler.handle(LinkRepositoryToProjectCommand(p.id, RepositoryId.random()))
}

val ex =
assertThrows<NotFoundException> {
handler.handle(LinkRepositoryToProjectCommand(p.id, missing))
}

assertThat(ex.message).contains("Repository", missing.value.toString())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,9 +43,13 @@ class OpenPullRequestCommandHandlerTest {
fun `handle errors for unknown workspaces`() {
val id = WorkspaceId.random()
every { workspaces.findById(id) } returns null
assertThrows<IllegalStateException> {
handler.handle(OpenPullRequestCommand(id, "/x", "t", "b"))
}

val ex =
assertThrows<NotFoundException> {
handler.handle(OpenPullRequestCommand(id, "/x", "t", "b"))
}

assertThat(ex.message).contains("Workspace", id.value.toString())
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down