From 7263a64cd22218ef6c53d16147a3c77d2537035b Mon Sep 17 00:00:00 2001 From: pkdee Date: Wed, 1 Jul 2026 01:42:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C(=EC=99=84=EB=A3=8C)=20=EC=B2=98=EB=A6=AC=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - events.completed_at 컬럼 추가(V12, nullable) - Event.completedAt + 파생 completed, EventService complete(멱등)/reopen - POST /events/{id}/complete·reopen, EventResponse 에 completed·completedAt - Service 단위·Controller 슬라이스 테스트 추가 Closes #55 --- .../event/controller/EventController.kt | 20 +++++++ .../app/ieoseo/server/event/domain/Event.kt | 11 ++++ .../app/ieoseo/server/event/dto/EventDtos.kt | 6 +++ .../server/event/service/EventService.kt | 17 ++++++ .../db/migration/V12__event_completed_at.sql | 4 ++ .../controller/EventControllerWebMvcTest.kt | 29 +++++++++++ .../server/event/service/EventServiceTest.kt | 52 +++++++++++++++++++ 7 files changed, 139 insertions(+) create mode 100644 src/main/resources/db/migration/V12__event_completed_at.sql diff --git a/src/main/kotlin/app/ieoseo/server/event/controller/EventController.kt b/src/main/kotlin/app/ieoseo/server/event/controller/EventController.kt index a7cb317..b80190a 100644 --- a/src/main/kotlin/app/ieoseo/server/event/controller/EventController.kt +++ b/src/main/kotlin/app/ieoseo/server/event/controller/EventController.kt @@ -72,6 +72,26 @@ class EventController( @AuthenticationPrincipal principal: AuthPrincipal, @PathVariable id: UUID, ) = eventService.delete(principal.userId, id) + + /** 종료(완료) 처리 — 홈 목록에서 숨긴다. 자동 삭제 대신 유저가 명시적으로 종료(FRD 5.1). */ + @PostMapping("/{id}/complete") + fun complete( + @AuthenticationPrincipal principal: AuthPrincipal, + @PathVariable id: UUID, + ): ApiResponse { + val event = eventService.complete(principal.userId, id) + return ApiResponse.ok(EventResponse.from(event, eventService.dDay(event))) + } + + /** 종료 취소(재개) — 다시 D-Day/D+ 로 노출한다. */ + @PostMapping("/{id}/reopen") + fun reopen( + @AuthenticationPrincipal principal: AuthPrincipal, + @PathVariable id: UUID, + ): ApiResponse { + val event = eventService.reopen(principal.userId, id) + return ApiResponse.ok(EventResponse.from(event, eventService.dDay(event))) + } } internal fun Page.toMeta(): Meta = Meta( diff --git a/src/main/kotlin/app/ieoseo/server/event/domain/Event.kt b/src/main/kotlin/app/ieoseo/server/event/domain/Event.kt index a5722a1..7b91333 100644 --- a/src/main/kotlin/app/ieoseo/server/event/domain/Event.kt +++ b/src/main/kotlin/app/ieoseo/server/event/domain/Event.kt @@ -8,6 +8,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Index import jakarta.persistence.Table +import java.time.Instant import java.time.LocalDate import java.util.UUID @@ -71,8 +72,18 @@ class Event( /** 표시 색상(헥스 등). 표현용 메타데이터. */ @Column(name = "color", length = 16) var color: String? = null, + + /** + * 종료(완료) 처리 시각. null = 미종료(D-Day/기간이 지나도 D+로 계속 노출), + * 값 있음 = 유저가 명시적으로 종료(완료) 처리(홈 목록에서 숨김). FRD 5.1. + */ + @Column(name = "completed_at") + var completedAt: Instant? = null, ) : BaseEntity() { + /** 종료(완료) 처리 여부 — [completedAt] 존재로 파생. */ + val completed: Boolean get() = completedAt != null + init { // 타입별 날짜 필수 불변식은 EventValidation(EventService 경유)에서 강제한다. require(title.isNotBlank()) { "title 은 비어 있을 수 없다" } diff --git a/src/main/kotlin/app/ieoseo/server/event/dto/EventDtos.kt b/src/main/kotlin/app/ieoseo/server/event/dto/EventDtos.kt index 616ca15..8c0e73f 100644 --- a/src/main/kotlin/app/ieoseo/server/event/dto/EventDtos.kt +++ b/src/main/kotlin/app/ieoseo/server/event/dto/EventDtos.kt @@ -67,6 +67,10 @@ data class EventResponse( val pinned: Boolean, val memo: String?, val color: String?, + /** 종료(완료) 여부 — true 면 클라이언트가 홈 "다가오는 일정"에서 숨긴다(FRD 5.1). */ + val completed: Boolean, + /** 종료(완료) 처리 시각. 미종료면 null. */ + val completedAt: Instant?, /** server 권위 D-Day 파생 계산(FRD 5.1). 계산 컨텍스트가 없으면 null. */ val dday: DDaySummary?, val createdAt: Instant, @@ -84,6 +88,8 @@ data class EventResponse( pinned = event.pinned, memo = event.memo, color = event.color, + completed = event.completed, + completedAt = event.completedAt, dday = dDay?.let(DDaySummary::from), createdAt = event.createdAt, updatedAt = event.updatedAt, diff --git a/src/main/kotlin/app/ieoseo/server/event/service/EventService.kt b/src/main/kotlin/app/ieoseo/server/event/service/EventService.kt index b24dd6b..3c0cf79 100644 --- a/src/main/kotlin/app/ieoseo/server/event/service/EventService.kt +++ b/src/main/kotlin/app/ieoseo/server/event/service/EventService.kt @@ -12,6 +12,7 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Instant import java.time.LocalDate import java.util.UUID @@ -64,4 +65,20 @@ class EventService( val event = findById(userId, id) eventRepository.delete(event) } + + /** 종료(완료) 처리. 이미 종료면 멱등(시각 유지). 홈 목록에서 숨겨진다(FRD 5.1). */ + @Transactional + fun complete(userId: UUID, id: UUID, now: Instant = Instant.now()): Event { + val event = findById(userId, id) + if (event.completedAt == null) event.completedAt = now + return event + } + + /** 종료 취소(재개). 다시 D-Day/D+ 로 노출된다. 이미 미종료면 멱등. */ + @Transactional + fun reopen(userId: UUID, id: UUID): Event { + val event = findById(userId, id) + event.completedAt = null + return event + } } diff --git a/src/main/resources/db/migration/V12__event_completed_at.sql b/src/main/resources/db/migration/V12__event_completed_at.sql new file mode 100644 index 0000000..350b463 --- /dev/null +++ b/src/main/resources/db/migration/V12__event_completed_at.sql @@ -0,0 +1,4 @@ +-- V12: 이벤트 종료(완료) 처리 지원(FRD 5.1). +-- D-Day/기간이 지나도(D+) 자동 삭제하지 않고, 유저가 명시적으로 "종료 처리"할 때 시각을 기록한다. +-- null = 미종료(진행/경과, 홈에 계속 노출), 값 있음 = 종료(완료, 홈 목록에서 숨김). 백필 없음. +ALTER TABLE events ADD COLUMN completed_at TIMESTAMPTZ; diff --git a/src/test/kotlin/app/ieoseo/server/event/controller/EventControllerWebMvcTest.kt b/src/test/kotlin/app/ieoseo/server/event/controller/EventControllerWebMvcTest.kt index a88090a..a454d3f 100644 --- a/src/test/kotlin/app/ieoseo/server/event/controller/EventControllerWebMvcTest.kt +++ b/src/test/kotlin/app/ieoseo/server/event/controller/EventControllerWebMvcTest.kt @@ -26,6 +26,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.Instant import java.time.LocalDate import java.util.UUID @@ -116,6 +117,29 @@ class EventControllerWebMvcTest { .andExpect(jsonPath("$.error.details[0].field").value("title")) } + @Test + fun `종료 처리는 completed=true 로 응답한다`() { + val id = UUID.randomUUID() + val event = Event( + id = id, + userId = userId, + type = EventType.T1_DDAY, + title = "정처기 실기", + date = LocalDate.of(2026, 8, 2), + completedAt = Instant.parse("2026-07-01T00:00:00Z"), + ) + `when`(eventService.complete(eqv(userId), eqv(id), anyInstant())).thenReturn(event) + `when`(eventService.dDay(anyEvent(), anyDate())).thenReturn( + DDayResult(daysRemaining = 32, label = "D-32", phase = EventPhase.UPCOMING, urgency = Urgency.LOW), + ) + + mockMvc.perform(post("/api/v1/events/{id}/complete", id).with(authentication(asUser()))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.completed").value(true)) + .andExpect(jsonPath("$.data.completedAt").exists()) + } + /** Kotlin non-null 파라미터용 Mockito any() 헬퍼(매처가 null 을 반환해 NPE 나는 문제 회피). */ private fun anyEvent(): Event { org.mockito.ArgumentMatchers.any(Event::class.java) @@ -126,4 +150,9 @@ class EventControllerWebMvcTest { org.mockito.ArgumentMatchers.any(LocalDate::class.java) return LocalDate.now() } + + private fun anyInstant(): Instant { + org.mockito.ArgumentMatchers.any(Instant::class.java) + return Instant.now() + } } diff --git a/src/test/kotlin/app/ieoseo/server/event/service/EventServiceTest.kt b/src/test/kotlin/app/ieoseo/server/event/service/EventServiceTest.kt index 63e370a..10a7173 100644 --- a/src/test/kotlin/app/ieoseo/server/event/service/EventServiceTest.kt +++ b/src/test/kotlin/app/ieoseo/server/event/service/EventServiceTest.kt @@ -12,6 +12,7 @@ import org.mockito.ArgumentMatchers import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` +import java.time.Instant import java.time.LocalDate import java.util.Optional import java.util.UUID @@ -72,4 +73,55 @@ class EventServiceTest { assertThrows { service.delete(owner, id) } } + + @Test + fun `종료 처리는 completedAt 을 세팅한다`() { + val id = UUID.randomUUID() + val event = Event(id = id, userId = owner, type = EventType.T1_DDAY, title = "x", date = LocalDate.of(2026, 8, 2)) + `when`(eventRepository.findByIdAndUserId(id, owner)).thenReturn(Optional.of(event)) + val now = Instant.parse("2026-07-01T00:00:00Z") + + val result = service.complete(owner, id, now) + + assertEquals(now, result.completedAt) + assertEquals(true, result.completed) + } + + @Test + fun `종료 처리는 멱등이다 - 이미 종료면 최초 시각을 유지한다`() { + val id = UUID.randomUUID() + val first = Instant.parse("2026-06-01T00:00:00Z") + val event = Event( + id = id, userId = owner, type = EventType.T1_DDAY, title = "x", + date = LocalDate.of(2026, 8, 2), completedAt = first, + ) + `when`(eventRepository.findByIdAndUserId(id, owner)).thenReturn(Optional.of(event)) + + val result = service.complete(owner, id, Instant.parse("2026-07-01T00:00:00Z")) + + assertEquals(first, result.completedAt) + } + + @Test + fun `종료 취소는 completedAt 을 null 로 되돌린다`() { + val id = UUID.randomUUID() + val event = Event( + id = id, userId = owner, type = EventType.T1_DDAY, title = "x", + date = LocalDate.of(2026, 8, 2), completedAt = Instant.parse("2026-06-01T00:00:00Z"), + ) + `when`(eventRepository.findByIdAndUserId(id, owner)).thenReturn(Optional.of(event)) + + val result = service.reopen(owner, id) + + assertEquals(null, result.completedAt) + assertEquals(false, result.completed) + } + + @Test + fun `없거나 타인 이벤트 종료 처리는 404 로 차단된다`() { + val id = UUID.randomUUID() + `when`(eventRepository.findByIdAndUserId(id, owner)).thenReturn(Optional.empty()) + + assertThrows { service.complete(owner, id) } + } }