feat(agent-assignment): 에이전트 크리에이터 소속 관리 기능을 추가한다

This commit is contained in:
2026-04-10 02:23:10 +09:00
parent 308b79fded
commit b84f70a6bf
8 changed files with 545 additions and 0 deletions

View File

@@ -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)
}
}

View File

@@ -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))
}
}