From 28a9b45b1c32110fefd8bd84590fc94bfca77d5d Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Fri, 14 Aug 2026 20:18:39 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=A0=84=EA=B5=AD=20=EC=86=8D?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20=EC=B5=9C=EA=B7=BC=2024=EC=8B=9C=EA=B0=84=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EC=A0=9C=EB=B3=B4=EB=A5=BC=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=EC=88=9C=20=EC=B5=9C=EB=8C=80=2020=EA=B1=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20-=20=EC=84=A0=ED=83=9D=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EA=B3=BC=20=EC=96=91=EB=B0=A9=ED=96=A5=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=20=EC=A0=81=EC=9A=A9=20-=20?= =?UTF-8?q?=EB=82=A0=EC=94=A8=20=EC=83=81=ED=83=9C=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=EC=88=9C=EC=9C=84=20=EA=B8=B0=EB=B0=98=20=EC=86=8D=EB=B3=B4=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=83=9D=EC=84=B1=20-=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=EC=9D=BC=EA=B4=84=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=99=80=20=EC=A0=84=EA=B5=AD=20=EC=B5=9C=EC=8B=A0=EC=88=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EA=B0=9C=EC=9D=B8=ED=99=94=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=BA=90=EC=8B=9C=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F?= =?UTF-8?q?=20API=20=EA=B3=84=EC=95=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/V15__add_live_report_index.sql | 11 ++ .../report/api/ReportController.java | 16 +++ .../report/api/dto/LiveReportsResponse.java | 49 +++++++ .../LiveReportMessageResolver.java | 48 +++++++ .../report/application/ReportService.java | 28 ++++ .../application/dto/LiveReportData.java | 16 +++ .../repository/WeatherReportRepository.java | 33 +++++ .../report/api/ReportControllerLiveTest.java | 99 ++++++++++++++ .../LiveReportMessageResolverTest.java | 46 +++++++ .../application/LiveReportServiceTest.java | 121 ++++++++++++++++++ 10 files changed, 467 insertions(+) create mode 100644 app/src/main/resources/db/migration/V15__add_live_report_index.sql create mode 100644 report/src/main/java/com/nalssilog/report/api/dto/LiveReportsResponse.java create mode 100644 report/src/main/java/com/nalssilog/report/application/LiveReportMessageResolver.java create mode 100644 report/src/main/java/com/nalssilog/report/application/dto/LiveReportData.java create mode 100644 report/src/test/java/com/nalssilog/report/api/ReportControllerLiveTest.java create mode 100644 report/src/test/java/com/nalssilog/report/application/LiveReportMessageResolverTest.java create mode 100644 report/src/test/java/com/nalssilog/report/application/LiveReportServiceTest.java diff --git a/app/src/main/resources/db/migration/V15__add_live_report_index.sql b/app/src/main/resources/db/migration/V15__add_live_report_index.sql new file mode 100644 index 0000000..42cbdf1 --- /dev/null +++ b/app/src/main/resources/db/migration/V15__add_live_report_index.sql @@ -0,0 +1,11 @@ +CREATE INDEX idx_weather_report_live_status_created + ON weather_report (moderation_status, created_at DESC, id DESC) + INCLUDE ( + location_id, + author_type, + author_member_id, + author_anonymous_key, + temperature, + precipitation, + sunlight + ); diff --git a/report/src/main/java/com/nalssilog/report/api/ReportController.java b/report/src/main/java/com/nalssilog/report/api/ReportController.java index 51ed2c0..ed10034 100644 --- a/report/src/main/java/com/nalssilog/report/api/ReportController.java +++ b/report/src/main/java/com/nalssilog/report/api/ReportController.java @@ -3,6 +3,7 @@ import com.nalssilog.common.response.CursorPage; import com.nalssilog.report.api.dto.CreateReportRequest; import com.nalssilog.report.api.dto.CreateReportFlagRequest; +import com.nalssilog.report.api.dto.LiveReportsResponse; import com.nalssilog.report.api.dto.ReportFlagResponse; import com.nalssilog.report.application.ActorRestrictionService; import com.nalssilog.report.application.ReportFlagService; @@ -18,7 +19,9 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -86,6 +89,19 @@ public CursorPage list( return reportService.list(locationId, cursor, viewer, actorResolver.resolveForOwnership(memberId, httpRequest)); } + @GetMapping("/live") + public ResponseEntity live( + @AuthenticationPrincipal Long memberId, + HttpServletRequest httpRequest + ) { + ReportActor viewer = actorResolver.resolveForRead(memberId, httpRequest); + + return ResponseEntity.ok() + .header(HttpHeaders.CACHE_CONTROL, "private, no-store") + .header(HttpHeaders.PRAGMA, "no-cache") + .body(reportService.live(viewer)); + } + @GetMapping("/me") public CursorPage myReports( @AuthenticationPrincipal Long memberId, diff --git a/report/src/main/java/com/nalssilog/report/api/dto/LiveReportsResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/LiveReportsResponse.java new file mode 100644 index 0000000..8c4ee43 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/api/dto/LiveReportsResponse.java @@ -0,0 +1,49 @@ +package com.nalssilog.report.api.dto; + +import com.nalssilog.report.application.dto.LiveReportData; +import com.nalssilog.report.application.dto.LocationSummary; +import java.time.Instant; +import java.util.List; + +public record LiveReportsResponse(List items) { + + public LiveReportsResponse { + items = List.copyOf(items); + } + + public record Item( + String reportId, + Location location, + String message, + Instant createdAt + ) { + + public static Item of(LiveReportData report, LocationSummary location, String message) { + return new Item( + String.valueOf(report.id()), + Location.from(location), + message, + report.createdAt()); + } + } + + public record Location( + String id, + String sido, + String sigungu, + String dong, + String label, + String shortLabel + ) { + + public static Location from(LocationSummary location) { + return new Location( + String.valueOf(location.id()), + location.sido(), + location.sigungu(), + location.dong(), + location.label(), + location.shortLabel()); + } + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/LiveReportMessageResolver.java b/report/src/main/java/com/nalssilog/report/application/LiveReportMessageResolver.java new file mode 100644 index 0000000..9dabc29 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/LiveReportMessageResolver.java @@ -0,0 +1,48 @@ +package com.nalssilog.report.application; + +import com.nalssilog.report.application.dto.LiveReportData; +import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.Sunlight; +import com.nalssilog.report.domain.Temperature; + +final class LiveReportMessageResolver { + + private LiveReportMessageResolver() { + } + + static String resolve(LiveReportData report) { + if (report.precipitation() == Precipitation.HEAVY) { + return "비가 많이 와요"; + } + + if (report.precipitation() == Precipitation.LIGHT) { + return "비가 조금 와요"; + } + + if (report.temperature() == Temperature.HOT) { + return "더워요"; + } + + if (report.temperature() == Temperature.COLD) { + return "추워요"; + } + + if (report.sunlight() == Sunlight.STRONG) { + return "햇빛이 따가워요"; + } + + if (report.sunlight() == Sunlight.LOW) { + return "햇빛이 부족해요"; + } + + if (report.temperature() == Temperature.FRESH) { + return "선선해요"; + } + + if (report.sunlight() == Sunlight.MODERATE) { + return "햇빛이 적당해요"; + } + + return "지금 비가 안 와요"; + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ReportService.java b/report/src/main/java/com/nalssilog/report/application/ReportService.java index bfa39fe..2178adf 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportService.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportService.java @@ -3,11 +3,13 @@ import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.common.response.CursorPage; import com.nalssilog.member.application.dto.TermsAgreement; +import com.nalssilog.report.api.dto.LiveReportsResponse; import com.nalssilog.report.api.dto.ReportResponse; import com.nalssilog.report.api.dto.ThanksResponse; import com.nalssilog.report.api.dto.WeatherStatsResponse; import com.nalssilog.report.application.dto.AuthorInfo; import com.nalssilog.report.application.dto.CreateReportCommand; +import com.nalssilog.report.application.dto.LiveReportData; import com.nalssilog.report.application.dto.LocationSummary; import com.nalssilog.report.application.dto.ReportActor; import com.nalssilog.report.application.dto.ReportData; @@ -40,6 +42,8 @@ public class ReportService { private static final int PAGE_SIZE = 20; private static final Duration STATS_WINDOW = Duration.ofHours(3); + private static final int LIVE_REPORT_LIMIT = 20; + private static final Duration LIVE_REPORT_WINDOW = Duration.ofHours(24); private final WeatherReportRepository reportRepository; private final ThanksRepository thanksRepository; @@ -114,6 +118,30 @@ public CursorPage list(Long locationId, String cursor, ReportAct return CursorPage.of(items, nextCursor); } + public LiveReportsResponse live(ReportActor viewer) { + return liveAt(viewer, Instant.now()); + } + + LiveReportsResponse liveAt(ReportActor viewer, Instant now) { + List reports = reportRepository.findLiveReports( + now.minus(LIVE_REPORT_WINDOW), viewer, LIVE_REPORT_LIMIT); + + if (reports.isEmpty()) { + return new LiveReportsResponse(List.of()); + } + + Map locations = locationClient.getLocations( + reports.stream().map(LiveReportData::locationId).distinct().toList()); + List items = reports.stream() + .map(report -> LiveReportsResponse.Item.of( + report, + locations.get(report.locationId()), + LiveReportMessageResolver.resolve(report))) + .toList(); + + return new LiveReportsResponse(items); + } + /** * 특정 회원이 작성한 제보 목록(내 제보 / 회원별 제보 공용). 여러 지역에 걸치므로 지역은 배치로 enrich 한다. * 대상 회원이 탈퇴 상태면 작성자 정보는 노출하지 않고 "익명 이웃"으로 렌더된다. diff --git a/report/src/main/java/com/nalssilog/report/application/dto/LiveReportData.java b/report/src/main/java/com/nalssilog/report/application/dto/LiveReportData.java new file mode 100644 index 0000000..4cfaaa6 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/dto/LiveReportData.java @@ -0,0 +1,16 @@ +package com.nalssilog.report.application.dto; + +import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.Sunlight; +import com.nalssilog.report.domain.Temperature; +import java.time.Instant; + +public record LiveReportData( + Long id, + Long locationId, + Temperature temperature, + Precipitation precipitation, + Sunlight sunlight, + Instant createdAt +) { +} diff --git a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java index c403c8b..e28669e 100644 --- a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java @@ -5,6 +5,7 @@ import static com.nalssilog.report.domain.QActorBlock.actorBlock; import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.application.dto.LiveReportData; import com.nalssilog.report.application.dto.PopularLocationAggregate; import com.nalssilog.report.application.dto.ReportData; import com.nalssilog.report.application.dto.ReportActor; @@ -145,6 +146,38 @@ public List findPage( .toList(); } + public List findLiveReports( + Instant since, + ReportActor viewer, + int limit + ) { + return queryFactory + .select( + weatherReport.id, + weatherReport.locationId, + weatherReport.temperature, + weatherReport.precipitation, + weatherReport.sunlight, + weatherReport.createdAt) + .from(weatherReport) + .where( + weatherReport.moderationStatus.eq(ModerationStatus.VISIBLE), + weatherReport.createdAt.goe(since), + withoutBlockRelation(viewer)) + .orderBy(weatherReport.createdAt.desc(), weatherReport.id.desc()) + .limit(limit) + .fetch() + .stream() + .map(row -> new LiveReportData( + row.get(weatherReport.id), + row.get(weatherReport.locationId), + row.get(weatherReport.temperature), + row.get(weatherReport.precipitation), + row.get(weatherReport.sunlight), + row.get(weatherReport.createdAt))) + .toList(); + } + public List findMemberPage(Long memberId, Instant cursorTime, Long cursorId, int limit) { return findMemberPage(memberId, cursorTime, cursorId, null, limit); } diff --git a/report/src/test/java/com/nalssilog/report/api/ReportControllerLiveTest.java b/report/src/test/java/com/nalssilog/report/api/ReportControllerLiveTest.java new file mode 100644 index 0000000..6bbb4f9 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/api/ReportControllerLiveTest.java @@ -0,0 +1,99 @@ +package com.nalssilog.report.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.nalssilog.report.api.dto.LiveReportsResponse; +import com.nalssilog.report.application.ActorRestrictionService; +import com.nalssilog.report.application.ReportFlagService; +import com.nalssilog.report.application.ReportRateLimiter; +import com.nalssilog.report.application.ReportService; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.config.ReportActorResolver; +import com.nalssilog.report.config.ReportClientIpResolver; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@SuppressWarnings("java:S5960") +class ReportControllerLiveTest { + + private final ReportService reportService = mock(ReportService.class); + private final ReportActorResolver actorResolver = mock(ReportActorResolver.class); + private final ReportController controller = new ReportController( + reportService, + actorResolver, + mock(ReportClientIpResolver.class), + mock(ReportRateLimiter.class), + mock(ActorRestrictionService.class), + mock(ReportFlagService.class)); + private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + + @Test + void reflectsOptionalMemberAuthenticationAndDisablesSharedCaching() { + MockHttpServletRequest request = new MockHttpServletRequest(); + ReportActor viewer = ReportActor.member(7L); + LiveReportsResponse body = new LiveReportsResponse(List.of()); + + when(actorResolver.resolveForRead(7L, request)).thenReturn(viewer); + when(reportService.live(viewer)).thenReturn(body); + + ResponseEntity response = controller.live(7L, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL)) + .isEqualTo("private, no-store"); + assertThat(response.getHeaders().getFirst(HttpHeaders.PRAGMA)).isEqualTo("no-cache"); + assertThat(response.getBody()).isSameAs(body); + verify(actorResolver).resolveForRead(7L, request); + verify(reportService).live(viewer); + } + + @Test + void returnsThePublicLiveResponseContractForAnAnonymousRequest() throws Exception { + LiveReportsResponse body = new LiveReportsResponse(List.of( + new LiveReportsResponse.Item( + "987654321098765432", + new LiveReportsResponse.Location( + "123456789012345678", + "서울특별시", + "강남구", + "역삼동", + "서울특별시 강남구 역삼동", + "강남구 역삼동"), + "비가 많이 와요", + Instant.parse("2026-08-14T08:30:00Z")))); + + when(actorResolver.resolveForRead(isNull(), any(HttpServletRequest.class))) + .thenReturn(null); + when(reportService.live(null)).thenReturn(body); + + mockMvc.perform(get("/api/reports/live")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "private, no-store")) + .andExpect(header().string(HttpHeaders.PRAGMA, "no-cache")) + .andExpect(jsonPath("$.items[0].reportId").value("987654321098765432")) + .andExpect(jsonPath("$.items[0].location.id").value("123456789012345678")) + .andExpect(jsonPath("$.items[0].location.shortLabel").value("강남구 역삼동")) + .andExpect(jsonPath("$.items[0].message").value("비가 많이 와요")) + .andExpect(jsonPath("$.items[0].createdAt").value("2026-08-14T08:30:00Z")); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/LiveReportMessageResolverTest.java b/report/src/test/java/com/nalssilog/report/application/LiveReportMessageResolverTest.java new file mode 100644 index 0000000..67faa3d --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/LiveReportMessageResolverTest.java @@ -0,0 +1,46 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nalssilog.report.application.dto.LiveReportData; +import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.Sunlight; +import com.nalssilog.report.domain.Temperature; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class LiveReportMessageResolverTest { + + @Test + void resolvesTheFirstMatchingWeatherStateAcrossAllAxes() { + assertThat(resolve(Temperature.HOT, Precipitation.HEAVY, Sunlight.STRONG)) + .isEqualTo("비가 많이 와요"); + assertThat(resolve(Temperature.COLD, Precipitation.LIGHT, Sunlight.LOW)) + .isEqualTo("비가 조금 와요"); + assertThat(resolve(Temperature.HOT, Precipitation.NONE, Sunlight.STRONG)) + .isEqualTo("더워요"); + assertThat(resolve(Temperature.COLD, Precipitation.NONE, Sunlight.LOW)) + .isEqualTo("추워요"); + assertThat(resolve(Temperature.FRESH, Precipitation.NONE, Sunlight.STRONG)) + .isEqualTo("햇빛이 따가워요"); + assertThat(resolve(Temperature.FRESH, Precipitation.NONE, Sunlight.LOW)) + .isEqualTo("햇빛이 부족해요"); + assertThat(resolve(Temperature.FRESH, Precipitation.NONE, Sunlight.MODERATE)) + .isEqualTo("선선해요"); + } + + private String resolve( + Temperature temperature, + Precipitation precipitation, + Sunlight sunlight + ) { + return LiveReportMessageResolver.resolve(new LiveReportData( + 1L, + 2L, + temperature, + precipitation, + sunlight, + Instant.EPOCH)); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/LiveReportServiceTest.java b/report/src/test/java/com/nalssilog/report/application/LiveReportServiceTest.java new file mode 100644 index 0000000..bd16b94 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/LiveReportServiceTest.java @@ -0,0 +1,121 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nalssilog.report.api.dto.LiveReportsResponse; +import com.nalssilog.report.application.dto.LiveReportData; +import com.nalssilog.report.application.dto.LocationSummary; +import com.nalssilog.report.application.dto.ReportActor; +import com.nalssilog.report.client.ImageStorageClient; +import com.nalssilog.report.client.LocationClient; +import com.nalssilog.report.client.MemberClient; +import com.nalssilog.report.domain.Precipitation; +import com.nalssilog.report.domain.Sunlight; +import com.nalssilog.report.domain.Temperature; +import com.nalssilog.report.repository.ThanksRepository; +import com.nalssilog.report.repository.WeatherReportRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; + +@SuppressWarnings("java:S5960") +class LiveReportServiceTest { + + private final WeatherReportRepository reportRepository = mock(WeatherReportRepository.class); + private final ThanksRepository thanksRepository = mock(ThanksRepository.class); + private final MemberClient memberClient = mock(MemberClient.class); + private final LocationClient locationClient = mock(LocationClient.class); + private final ImageStorageClient imageStorageClient = mock(ImageStorageClient.class); + private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + + private ReportService service; + + @BeforeEach + void setUp() { + service = new ReportService( + reportRepository, + thanksRepository, + memberClient, + locationClient, + imageStorageClient, + eventPublisher, + new ReportConsentPolicy()); + } + + @Test + void returnsLatestTwentyFourHourReportsWithLocationsAndGeneratedMessages() { + Instant now = Instant.parse("2026-08-14T09:00:00Z"); + ReportActor viewer = ReportActor.member(7L); + LiveReportData first = report( + 31L, 101L, Temperature.HOT, Precipitation.HEAVY, Sunlight.STRONG, + Instant.parse("2026-08-14T08:30:00Z")); + LiveReportData second = report( + 30L, 102L, Temperature.HOT, Precipitation.NONE, Sunlight.MODERATE, + Instant.parse("2026-08-14T08:20:00Z")); + + when(reportRepository.findLiveReports(now.minus(Duration.ofHours(24)), viewer, 20)) + .thenReturn(List.of(first, second)); + when(locationClient.getLocations(List.of(101L, 102L))).thenReturn(Map.of( + 101L, location(101L, "서울특별시", "강남구", "역삼동"), + 102L, location(102L, "부산광역시", "해운대구", "우동"))); + + LiveReportsResponse response = service.liveAt(viewer, now); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().getFirst()) + .satisfies(item -> { + assertThat(item.reportId()).isEqualTo("31"); + assertThat(item.location().id()).isEqualTo("101"); + assertThat(item.location().label()).isEqualTo("서울특별시 강남구 역삼동"); + assertThat(item.location().shortLabel()).isEqualTo("강남구 역삼동"); + assertThat(item.message()).isEqualTo("비가 많이 와요"); + assertThat(item.createdAt()).isEqualTo(Instant.parse("2026-08-14T08:30:00Z")); + }); + assertThat(response.items().get(1).message()).isEqualTo("더워요"); + verify(reportRepository).findLiveReports(now.minus(Duration.ofHours(24)), viewer, 20); + verify(locationClient).getLocations(List.of(101L, 102L)); + } + + @Test + void returnsEmptyItemsWithoutLoadingLocationsWhenNoLiveReportExists() { + Instant now = Instant.parse("2026-08-14T09:00:00Z"); + + when(reportRepository.findLiveReports(now.minus(Duration.ofHours(24)), null, 20)) + .thenReturn(List.of()); + + LiveReportsResponse response = service.liveAt(null, now); + + assertThat(response.items()).isEmpty(); + verifyNoInteractions(locationClient); + } + + private LiveReportData report( + Long id, + Long locationId, + Temperature temperature, + Precipitation precipitation, + Sunlight sunlight, + Instant createdAt + ) { + return new LiveReportData( + id, locationId, temperature, precipitation, sunlight, createdAt); + } + + private LocationSummary location(Long id, String sido, String sigungu, String dong) { + return new LocationSummary( + id, + sido, + sigungu, + dong, + String.join(" ", sido, sigungu, dong), + String.join(" ", sigungu, dong)); + } +} From a988afc26b756b249927cf441a94011b6bd7fe48 Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Fri, 14 Aug 2026 20:35:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=200.4.0=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index e6d088f..3c1d7ea 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -appVersion=0.3.1 +appVersion=0.4.0