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 index a76f0efd..ca56de59 100644 --- 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 @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.admin.partner.agent.assignment import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.extensions.convertToUtc import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation @@ -37,14 +38,15 @@ class AdminAgentCreatorService( } val existingRelations = relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(request.creatorId) - if (hasAssignmentOverlap(existingRelations, request.assignedAt)) { + val assignedAt = request.assignedAt.convertToUtc() + if (hasAssignmentOverlap(existingRelations, assignedAt)) { throw SodaException(messageKey = "partner.agent.assignment.assignment_overlap") } val relation = AgentCreatorRelation() relation.agent = agent relation.creator = creator - relation.assignedAt = request.assignedAt + relation.assignedAt = assignedAt try { relationRepository.saveAndFlush(relation) } catch (e: DataIntegrityViolationException) { @@ -67,11 +69,12 @@ class AdminAgentCreatorService( val assignedAt = relation.assignedAt ?: throw SodaException(messageKey = "partner.agent.assignment.not_found") - if (!request.unassignedAt.isAfter(assignedAt)) { + val unassignedAt = request.unassignedAt.convertToUtc() + if (!unassignedAt.isAfter(assignedAt)) { throw SodaException(messageKey = "partner.agent.assignment.invalid_unassigned_at") } - relation.unassignedAt = request.unassignedAt + relation.unassignedAt = unassignedAt relationRepository.save(relation) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt index 061e19c2..f1dea6c3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt @@ -2,6 +2,10 @@ package kr.co.vividnext.sodalive.extensions import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneId + +private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul") +private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC") fun LocalDateTime.getTimeAgoString(): String { val now = LocalDateTime.now() @@ -16,3 +20,9 @@ fun LocalDateTime.getTimeAgoString(): String { else -> "${duration.toDays() / 365}년전" } } + +fun LocalDateTime.convertToUtc(timeZone: ZoneId = DEFAULT_KST_ZONE_ID): LocalDateTime { + return atZone(timeZone) + .withZoneSameInstant(UTC_ZONE_ID) + .toLocalDateTime() +} 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 index 2881cbeb..f7c61a0a 100644 --- 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 @@ -39,6 +39,7 @@ class AdminAgentCreatorServiceTest { @DisplayName("관리자는 에이전트와 크리에이터를 소속으로 연결할 수 있다") fun shouldAssignCreatorToAgent() { val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val expectedUtcAssignedAt = LocalDateTime.of(2026, 4, 9, 1, 0) val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) agent.id = 11L val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR) @@ -55,7 +56,7 @@ class AdminAgentCreatorServiceTest { Mockito.verify(relationRepository).saveAndFlush(relationCaptor.capture()) assertEquals(agent, relationCaptor.value.agent) assertEquals(creator, relationCaptor.value.creator) - assertEquals(assignedAt, relationCaptor.value.assignedAt) + assertEquals(expectedUtcAssignedAt, relationCaptor.value.assignedAt) assertEquals(null, relationCaptor.value.unassignedAt) } @@ -253,6 +254,7 @@ class AdminAgentCreatorServiceTest { @DisplayName("종료된 이력이 있으면 이후 시각에 다시 소속 지정할 수 있다") fun shouldAssignCreatorWhenPreviousAssignmentAlreadyEnded() { val assignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) + val previousUnassignedAt = LocalDateTime.of(2026, 4, 9, 1, 0) val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) agent.id = 11L val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR) @@ -261,7 +263,7 @@ class AdminAgentCreatorServiceTest { existingRelation.agent = agent existingRelation.creator = creator existingRelation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0) - existingRelation.unassignedAt = assignedAt + existingRelation.unassignedAt = previousUnassignedAt val request = AssignAgentCreatorRequest(agentId = 11L, creatorId = 22L, assignedAt = assignedAt) Mockito.`when`(memberRepository.findById(11L)).thenReturn(Optional.of(agent)) @@ -278,6 +280,7 @@ class AdminAgentCreatorServiceTest { fun shouldCloseCreatorAssignmentWindow() { val relation = AgentCreatorRelation() relation.assignedAt = LocalDateTime.of(2026, 4, 1, 0, 0) + val expectedUtcUnassignedAt = LocalDateTime.of(2026, 4, 9, 1, 0) val request = RemoveAgentCreatorRequest( creatorId = 22L, unassignedAt = LocalDateTime.of(2026, 4, 9, 10, 0) @@ -294,10 +297,37 @@ class AdminAgentCreatorServiceTest { service.removeCreator(request) - assertEquals(request.unassignedAt, relation.unassignedAt) + assertEquals(expectedUtcUnassignedAt, relation.unassignedAt) Mockito.verify(relationRepository).save(relation) } + @Test + @DisplayName("소속 종료 시각 비교는 한국 시간 입력을 UTC로 변환한 뒤 수행한다") + fun shouldValidateUnassignedAtAfterConvertingKstToUtc() { + val relation = AgentCreatorRelation() + relation.assignedAt = LocalDateTime.of(2026, 4, 9, 1, 0) + val request = RemoveAgentCreatorRequest( + creatorId = 22L, + unassignedAt = LocalDateTime.of(2026, 4, 9, 9, 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)) + } + @Test @DisplayName("소속 정보가 없으면 해제할 수 없다") fun shouldThrowWhenAssignmentDoesNotExist() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensionsTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensionsTest.kt new file mode 100644 index 00000000..f72f2a7a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensionsTest.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.extensions + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.fail +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneId + +class LocalDateTimeExtensionsTest { + @Test + fun shouldConvertToUtcUsingKstByDefault() { + val localDateTime = LocalDateTime.of(2026, 4, 9, 10, 0) + + val utcDateTime = invokeConvertToUtc(localDateTime) + + assertEquals(LocalDateTime.of(2026, 4, 9, 1, 0), utcDateTime) + } + + @Test + fun shouldConvertToUtcUsingProvidedTimezone() { + val localDateTime = LocalDateTime.of(2026, 4, 9, 10, 0) + + val utcDateTime = invokeConvertToUtc(localDateTime, ZoneId.of("Asia/Bangkok")) + + assertEquals(LocalDateTime.of(2026, 4, 9, 3, 0), utcDateTime) + } + + private fun invokeConvertToUtc(localDateTime: LocalDateTime, timeZone: ZoneId = ZoneId.of("Asia/Seoul")): LocalDateTime { + return try { + val method = Class.forName("kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensionsKt") + .getMethod("convertToUtc", LocalDateTime::class.java, ZoneId::class.java) + method.invoke(null, localDateTime, timeZone) as LocalDateTime + } catch (e: ReflectiveOperationException) { + fail("LocalDateTime.convertToUtc 확장함수를 찾을 수 없습니다.") + } + } +}