From b84f70a6bf43b540c2260b39744688723a3542e8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Apr 2026 02:23:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-assignment):=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=20=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=86=8C=EC=86=8D=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assignment/AdminAgentCreatorController.kt | 21 ++ .../assignment/AdminAgentCreatorService.kt | 87 +++++ .../agent/assignment/AgentCreatorRelation.kt | 27 ++ .../AgentCreatorRelationRepository.kt | 9 + .../assignment/AssignAgentCreatorRequest.kt | 9 + .../assignment/RemoveAgentCreatorRequest.kt | 8 + .../AdminAgentCreatorControllerTest.kt | 50 +++ .../AdminAgentCreatorServiceTest.kt | 334 ++++++++++++++++++ 8 files changed, 545 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelationRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AssignAgentCreatorRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/RemoveAgentCreatorRequest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt new file mode 100644 index 00000000..dc835659 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorController.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.assignment + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.partner.agent.assignment.AssignAgentCreatorRequest +import kr.co.vividnext.sodalive.partner.agent.assignment.RemoveAgentCreatorRequest +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("/admin/partner/agent/assignment") +class AdminAgentCreatorController(private val service: AdminAgentCreatorService) { + @PostMapping + fun assignCreator(@RequestBody request: AssignAgentCreatorRequest) = ApiResponse.ok(service.assignCreator(request)) + + @PostMapping("/remove") + fun removeCreator(@RequestBody request: RemoveAgentCreatorRequest) = ApiResponse.ok(service.removeCreator(request)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt new file mode 100644 index 00000000..a76f0efd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorService.kt @@ -0,0 +1,87 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.assignment + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository +import kr.co.vividnext.sodalive.partner.agent.assignment.AssignAgentCreatorRequest +import kr.co.vividnext.sodalive.partner.agent.assignment.RemoveAgentCreatorRequest +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class AdminAgentCreatorService( + private val relationRepository: AgentCreatorRelationRepository, + private val memberRepository: MemberRepository +) { + @Transactional + fun assignCreator(request: AssignAgentCreatorRequest) { + if (request.agentId == request.creatorId) { + throw SodaException(messageKey = "partner.agent.assignment.invalid_relation") + } + + val agent = memberRepository.findByIdOrNull(request.agentId) + ?: throw SodaException(messageKey = "partner.agent.assignment.agent_not_found") + if (agent.role != MemberRole.AGENT) { + throw SodaException(messageKey = "partner.agent.assignment.invalid_agent") + } + + val creator = memberRepository.findByIdForUpdate(request.creatorId) + ?: throw SodaException(messageKey = "partner.agent.assignment.creator_not_found") + if (creator.role != MemberRole.CREATOR) { + throw SodaException(messageKey = "partner.agent.assignment.invalid_creator") + } + + val existingRelations = relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(request.creatorId) + if (hasAssignmentOverlap(existingRelations, request.assignedAt)) { + throw SodaException(messageKey = "partner.agent.assignment.assignment_overlap") + } + + val relation = AgentCreatorRelation() + relation.agent = agent + relation.creator = creator + relation.assignedAt = request.assignedAt + try { + relationRepository.saveAndFlush(relation) + } catch (e: DataIntegrityViolationException) { + relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(request.creatorId) + ?: throw e + throw SodaException(messageKey = "partner.agent.assignment.assignment_overlap") + } + } + + @Transactional + fun removeCreator(request: RemoveAgentCreatorRequest) { + val creator = memberRepository.findByIdForUpdate(request.creatorId) + ?: throw SodaException(messageKey = "partner.agent.assignment.creator_not_found") + if (creator.role != MemberRole.CREATOR) { + throw SodaException(messageKey = "partner.agent.assignment.invalid_creator") + } + + val relation = relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(request.creatorId) + ?: throw SodaException(messageKey = "partner.agent.assignment.not_found") + + val assignedAt = relation.assignedAt + ?: throw SodaException(messageKey = "partner.agent.assignment.not_found") + if (!request.unassignedAt.isAfter(assignedAt)) { + throw SodaException(messageKey = "partner.agent.assignment.invalid_unassigned_at") + } + + relation.unassignedAt = request.unassignedAt + relationRepository.save(relation) + } + + private fun hasAssignmentOverlap( + existingRelations: List, + assignedAt: LocalDateTime + ): Boolean { + return existingRelations.any { relation -> + val existingUnassignedAt = relation.unassignedAt + existingUnassignedAt == null || assignedAt.isBefore(existingUnassignedAt) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt new file mode 100644 index 00000000..0d3a9ae4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelation.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.partner.agent.assignment + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class AgentCreatorRelation : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "agent_id", nullable = false) + var agent: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + var creator: Member? = null + + @Column(nullable = false) + var assignedAt: LocalDateTime? = null + + @Column(nullable = true) + var unassignedAt: LocalDateTime? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelationRepository.kt new file mode 100644 index 00000000..9b952a40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AgentCreatorRelationRepository.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.partner.agent.assignment + +import org.springframework.data.jpa.repository.JpaRepository + +interface AgentCreatorRelationRepository : JpaRepository { + fun findAllByCreatorIdOrderByAssignedAtAsc(creatorId: Long): List + + fun findFirstByCreatorIdAndUnassignedAtIsNull(creatorId: Long): AgentCreatorRelation? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AssignAgentCreatorRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AssignAgentCreatorRequest.kt new file mode 100644 index 00000000..61c31a1c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/AssignAgentCreatorRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.partner.agent.assignment + +import java.time.LocalDateTime + +data class AssignAgentCreatorRequest( + val agentId: Long, + val creatorId: Long, + val assignedAt: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/RemoveAgentCreatorRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/RemoveAgentCreatorRequest.kt new file mode 100644 index 00000000..86de0ade --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/assignment/RemoveAgentCreatorRequest.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.partner.agent.assignment + +import java.time.LocalDateTime + +data class RemoveAgentCreatorRequest( + val creatorId: Long, + val unassignedAt: LocalDateTime +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorControllerTest.kt new file mode 100644 index 00000000..fa3f5a07 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorControllerTest.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.assignment + +import kr.co.vividnext.sodalive.partner.agent.assignment.AssignAgentCreatorRequest +import kr.co.vividnext.sodalive.partner.agent.assignment.RemoveAgentCreatorRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class AdminAgentCreatorControllerTest { + private lateinit var service: AdminAgentCreatorService + private lateinit var controller: AdminAgentCreatorController + + @BeforeEach + fun setup() { + service = Mockito.mock(AdminAgentCreatorService::class.java) + controller = AdminAgentCreatorController(service) + } + + @Test + @DisplayName("관리자 컨트롤러는 소속 지정 요청을 서비스로 전달한다") + fun shouldForwardAssignRequestToService() { + val request = AssignAgentCreatorRequest( + agentId = 11L, + creatorId = 22L, + assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + val response = controller.assignCreator(request) + + assertEquals(true, response.success) + Mockito.verify(service).assignCreator(request) + } + + @Test + @DisplayName("관리자 컨트롤러는 소속 해제 요청을 서비스로 전달한다") + fun shouldForwardRemoveRequestToService() { + val request = RemoveAgentCreatorRequest( + creatorId = 22L, + unassignedAt = LocalDateTime.of(2026, 4, 9, 11, 0) + ) + + val response = controller.removeCreator(request) + + assertEquals(true, response.success) + Mockito.verify(service).removeCreator(request) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorServiceTest.kt new file mode 100644 index 00000000..ab178c81 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/assignment/AdminAgentCreatorServiceTest.kt @@ -0,0 +1,334 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.assignment + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository +import kr.co.vividnext.sodalive.partner.agent.assignment.AssignAgentCreatorRequest +import kr.co.vividnext.sodalive.partner.agent.assignment.RemoveAgentCreatorRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.InOrder +import org.mockito.Mockito +import org.springframework.dao.DataIntegrityViolationException +import java.time.LocalDateTime +import java.util.Optional + +class AdminAgentCreatorServiceTest { + private lateinit var relationRepository: AgentCreatorRelationRepository + private lateinit var memberRepository: MemberRepository + private lateinit var service: AdminAgentCreatorService + + @BeforeEach + fun setup() { + relationRepository = Mockito.mock(AgentCreatorRelationRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + service = AdminAgentCreatorService( + relationRepository = relationRepository, + memberRepository = memberRepository + ) + } + + @Test + @DisplayName("관리자는 에이전트와 크리에이터를 소속으로 연결할 수 있다") + fun shouldAssignCreatorToAgent() { + val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 11L + val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR) + creator.id = 22L + val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator) + Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(emptyList()) + + service.assignCreator(request) + + val relationCaptor = ArgumentCaptor.forClass(AgentCreatorRelation::class.java) + Mockito.verify(relationRepository).saveAndFlush(relationCaptor.capture()) + assertEquals(agent, relationCaptor.value.agent) + assertEquals(creator, relationCaptor.value.creator) + assertEquals(assignedAt, relationCaptor.value.assignedAt) + assertEquals(null, relationCaptor.value.unassignedAt) + } + + @Test + @DisplayName("관리자 소속 지정은 creator row를 잠근 뒤 저장한다") + fun shouldLockCreatorBeforeAssigning() { + val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 11L + val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR) + creator.id = 22L + val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator) + Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(emptyList()) + + service.assignCreator(request) + + val inOrder: InOrder = Mockito.inOrder(memberRepository, relationRepository) + inOrder.verify(memberRepository).findById(11L) + inOrder.verify(memberRepository).findByIdForUpdate(22L) + inOrder.verify(relationRepository).findAllByCreatorIdOrderByAssignedAtAsc(22L) + inOrder.verify(relationRepository).saveAndFlush(Mockito.any(AgentCreatorRelation::class.java)) + } + + @Test + @DisplayName("동시 요청으로 소속 unique 제약이 충돌하면 중복 소속 예외로 변환한다") + fun shouldThrowAssignmentOverlapWhenConcurrentInsertViolatesUniqueConstraint() { + val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 11L + val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR) + creator.id = 22L + val existingRelation = AgentCreatorRelation() + existingRelation.agent = agent + existingRelation.creator = creator + existingRelation.assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator) + Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(emptyList()) + Mockito.`when`(relationRepository.saveAndFlush(Mockito.any(AgentCreatorRelation::class.java))) + .thenThrow(DataIntegrityViolationException("duplicate")) + Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(existingRelation) + + val exception = assertThrows(SodaException::class.java) { + service.assignCreator(request) + } + + assertEquals("partner.agent.assignment.assignment_overlap", exception.messageKey) + } + + @Test + @DisplayName("에이전트가 아니면 크리에이터를 소속으로 지정할 수 없다") + fun shouldThrowWhenMemberIsNotAgent() { + val invalidAgent = Member(password = "password", nickname = "user", role = MemberRole.USER) + invalidAgent.id = 11L + val request = AssignAgentCreatorRequest( + agentId = 11L, + creatorId = 22L, + assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(invalidAgent)) + + val exception = assertThrows(SodaException::class.java) { + service.assignCreator(request) + } + + assertEquals("partner.agent.assignment.invalid_agent", exception.messageKey) + Mockito.verify(memberRepository).findById(11L) + Mockito.verifyNoInteractions(relationRepository) + } + + @Test + @DisplayName("에이전트 회원이 없으면 소속 지정을 진행할 수 없다") + fun shouldThrowWhenAgentDoesNotExist() { + val request = AssignAgentCreatorRequest( + agentId = 11L, + creatorId = 22L, + assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.empty()) + + val exception = assertThrows(SodaException::class.java) { + service.assignCreator(request) + } + + assertEquals("partner.agent.assignment.agent_not_found", exception.messageKey) + Mockito.verify(memberRepository).findById(11L) + Mockito.verifyNoInteractions(relationRepository) + } + + @Test + @DisplayName("크리에이터가 아니면 에이전트에 소속시킬 수 없다") + fun shouldThrowWhenMemberIsNotCreator() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 11L + val invalidCreator = Member(password = "password", nickname = "user", role = MemberRole.USER) + invalidCreator.id = 22L + val request = AssignAgentCreatorRequest( + agentId = 11L, + creatorId = 22L, + assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(invalidCreator) + + val exception = assertThrows(SodaException::class.java) { + service.assignCreator(request) + } + + assertEquals("partner.agent.assignment.invalid_creator", exception.messageKey) + Mockito.verify(memberRepository).findById(11L) + Mockito.verify(memberRepository).findByIdForUpdate(22L) + Mockito.verifyNoInteractions(relationRepository) + } + + @Test + @DisplayName("크리에이터 회원이 없으면 소속 지정을 진행할 수 없다") + fun shouldThrowWhenCreatorDoesNotExist() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 11L + val request = AssignAgentCreatorRequest( + agentId = 11L, + creatorId = 22L, + assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(null) + + val exception = assertThrows(SodaException::class.java) { + service.assignCreator(request) + } + + assertEquals("partner.agent.assignment.creator_not_found", exception.messageKey) + Mockito.verify(memberRepository).findById(11L) + Mockito.verify(memberRepository).findByIdForUpdate(22L) + Mockito.verifyNoInteractions(relationRepository) + } + + @Test + @DisplayName("이미 다른 에이전트에 소속된 크리에이터는 중복 지정할 수 없다") + fun shouldThrowWhenCreatorIsAlreadyAssigned() { + val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 11L + val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR) + creator.id = 22L + val existingRelation = AgentCreatorRelation() + existingRelation.agent = agent + existingRelation.creator = creator + existingRelation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0) + existingRelation.unassignedAt = null + val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator) + Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(listOf(existingRelation)) + + val exception = assertThrows(SodaException::class.java) { + service.assignCreator(request) + } + + assertEquals("partner.agent.assignment.assignment_overlap", exception.messageKey) + Mockito.verify(relationRepository).findAllByCreatorIdOrderByAssignedAtAsc(22L) + Mockito.verify(relationRepository, Mockito.never()).save(Mockito.any(AgentCreatorRelation::class.java)) + Mockito.verify(relationRepository, Mockito.never()).saveAndFlush(Mockito.any(AgentCreatorRelation::class.java)) + } + + @Test + @DisplayName("종료된 이력이 있으면 이후 시각에 다시 소속 지정할 수 있다") + fun shouldAssignCreatorWhenPreviousAssignmentAlreadyEnded() { + val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 11L + val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR) + creator.id = 22L + val existingRelation = AgentCreatorRelation() + existingRelation.agent = agent + existingRelation.creator = creator + existingRelation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0) + existingRelation.unassignedAt = assignedAt + val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt) + + Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn(creator) + Mockito.`when`(relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(22L)).thenReturn(listOf(existingRelation)) + + service.assignCreator(request) + + Mockito.verify(relationRepository).saveAndFlush(Mockito.any(AgentCreatorRelation::class.java)) + } + + @Test + @DisplayName("관리자는 활성 소속의 종료 시각을 기록해 크리에이터 소속을 해제할 수 있다") + fun shouldCloseCreatorAssignmentWindow() { + val relation = AgentCreatorRelation() + relation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0) + val request = RemoveAgentCreatorRequest( + creatorId = 22L, + unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(relation) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn( + Member( + password = "password", + nickname = "creator", + role = MemberRole.CREATOR + ).also { it.id = 22L } + ) + + service.removeCreator(request) + + assertEquals(request.unassignedAt, relation.unassignedAt) + Mockito.verify(relationRepository).save(relation) + } + + @Test + @DisplayName("소속 정보가 없으면 해제할 수 없다") + fun shouldThrowWhenAssignmentDoesNotExist() { + val request = RemoveAgentCreatorRequest( + creatorId = 22L, + unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn( + Member( + password = "password", + nickname = "creator", + role = MemberRole.CREATOR + ).also { it.id = 22L } + ) + Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(null) + + val exception = assertThrows(SodaException::class.java) { + service.removeCreator(request) + } + + assertEquals("partner.agent.assignment.not_found", exception.messageKey) + Mockito.verify(relationRepository).findFirstByCreatorIdAndUnassignedAtIsNull(22L) + Mockito.verify(relationRepository, Mockito.never()).save(Mockito.any(AgentCreatorRelation::class.java)) + } + + @Test + @DisplayName("소속 종료 시각은 시작 시각보다 늦어야 한다") + fun shouldThrowWhenUnassignedAtIsNotAfterAssignedAt() { + val relation = AgentCreatorRelation() + relation.assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val request = RemoveAgentCreatorRequest( + creatorId = 22L, + unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(22L)).thenReturn( + Member( + password = "password", + nickname = "creator", + role = MemberRole.CREATOR + ).also { it.id = 22L } + ) + Mockito.`when`(relationRepository.findFirstByCreatorIdAndUnassignedAtIsNull(22L)).thenReturn(relation) + + val exception = assertThrows(SodaException::class.java) { + service.removeCreator(request) + } + + assertEquals("partner.agent.assignment.invalid_unassigned_at", exception.messageKey) + Mockito.verify(relationRepository, Mockito.never()).save(Mockito.any(AgentCreatorRelation::class.java)) + } +}