Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
.env.*
!.env.example
!*.env.example
!backend/src/test/resources/.env.properties

# Local generated data inputs and pipeline outputs
data/raw/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.ssafy.salmanhae.controller.map;

import java.math.BigDecimal;

import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.ssafy.salmanhae.common.response.ApiResponse;
import com.ssafy.salmanhae.model.dto.map.MapViewportRequest;
import com.ssafy.salmanhae.model.dto.map.MapViewportResponse;
import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria;
import com.ssafy.salmanhae.model.dto.property.PropertyType;
import com.ssafy.salmanhae.model.dto.property.TransactionType;
import com.ssafy.salmanhae.service.map.MapViewportService;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;

@RestController
@RequestMapping("/api/v1/map")
@Validated
public class MapViewportController {

private final MapViewportService mapViewportService;

public MapViewportController(MapViewportService mapViewportService) {
this.mapViewportService = mapViewportService;
}

@GetMapping("/viewport")
public ApiResponse<MapViewportResponse> getViewport(
@RequestParam BigDecimal west,
@RequestParam BigDecimal east,
@RequestParam BigDecimal south,
@RequestParam BigDecimal north,
@RequestParam @Min(0) @Max(21) Integer zoom,
@RequestParam(required = false) TransactionType transactionType,
@RequestParam(required = false) PropertyType propertyType,
@RequestParam(required = false) Long minDeposit,
@RequestParam(required = false) Long maxDeposit,
@RequestParam(required = false) Long minPrice,
@RequestParam(required = false) Long maxPrice,
@RequestParam(required = false) Integer clusterThreshold
) {
PropertySearchCriteria criteria = new PropertySearchCriteria(
west,
east,
south,
north,
transactionType,
propertyType,
minDeposit,
maxDeposit,
minPrice,
maxPrice
);
criteria.validateBounds();
return ApiResponse.ok(mapViewportService.getViewport(new MapViewportRequest(criteria, zoom, clusterThreshold)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.ssafy.salmanhae.model.dto.map;

public sealed interface MapViewportItemResponse permits RegionAverageViewportItem, PropertyClusterViewportItem, PropertyViewportItem {

MapViewportItemType type();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ssafy.salmanhae.model.dto.map;

public enum MapViewportItemType {
REGION_AVG,
CLUSTER,
PROPERTY
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ssafy.salmanhae.model.dto.map;

public enum MapViewportMode {
SIGUNGU_AVG,
DONG_AVG,
PROPERTY_CLUSTER,
PROPERTY_MARKER
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.ssafy.salmanhae.model.dto.map;

import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria;

public record MapViewportRequest(
PropertySearchCriteria criteria,
int zoom,
Integer clusterThreshold
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.ssafy.salmanhae.model.dto.map;

import java.util.List;

public record MapViewportResponse(
MapViewportMode mode,
List<MapViewportItemResponse> items,
int totalCount
) {

public static MapViewportResponse empty(MapViewportMode mode) {
return new MapViewportResponse(mode, List.of(), 0);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ssafy.salmanhae.model.dto.map;

import java.math.BigDecimal;

public record PropertyClusterViewportItem(
MapViewportItemType type,
String clusterId,
int count,
BigDecimal latitude,
BigDecimal longitude,
Integer radiusM,
Long avgDeposit,
Long avgMonthlyRent,
Long avgSalePrice
) implements MapViewportItemResponse {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.ssafy.salmanhae.model.dto.map;

import java.math.BigDecimal;

import com.ssafy.salmanhae.model.dto.property.TransactionType;

public record PropertyViewportItem(
MapViewportItemType type,
Long id,
String title,
TransactionType transactionType,
Long deposit,
Long monthlyRent,
Long price,
BigDecimal areaM2,
BigDecimal latitude,
BigDecimal longitude
) implements MapViewportItemResponse {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.ssafy.salmanhae.model.dto.map;

import java.math.BigDecimal;

public record RegionAverageViewportItem(
MapViewportItemType type,
String regionLevel,
String regionCode,
String regionName,
Long avgDeposit,
Long avgMonthlyRent,
Long avgSalePrice,
Integer transactionCount,
BigDecimal latitude,
BigDecimal longitude
) implements MapViewportItemResponse {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ssafy.salmanhae.service.map;

import com.ssafy.salmanhae.model.dto.map.MapViewportRequest;
import com.ssafy.salmanhae.model.dto.map.MapViewportResponse;

public interface MapViewportService {

MapViewportResponse getViewport(MapViewportRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.ssafy.salmanhae.service.map;

import org.springframework.stereotype.Service;

import com.ssafy.salmanhae.common.exception.ApiException;
import com.ssafy.salmanhae.common.exception.ErrorCode;
import com.ssafy.salmanhae.model.dto.map.MapViewportMode;
import com.ssafy.salmanhae.model.dto.map.MapViewportRequest;
import com.ssafy.salmanhae.model.dto.map.MapViewportResponse;

@Service
public class MapViewportServiceImpl implements MapViewportService {

@Override
public MapViewportResponse getViewport(MapViewportRequest request) {
if (request == null || request.criteria() == null) {
throw new ApiException(ErrorCode.INVALID_REQUEST);
}
request.criteria().validateBounds();
return MapViewportResponse.empty(resolveMode(request.zoom()));
}

private MapViewportMode resolveMode(int zoom) {
if (zoom <= 11) {
return MapViewportMode.SIGUNGU_AVG;
}
if (zoom <= 13) {
return MapViewportMode.DONG_AVG;
}
if (zoom <= 15) {
return MapViewportMode.PROPERTY_CLUSTER;
}
return MapViewportMode.PROPERTY_MARKER;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.ssafy.salmanhae.controller.map;

import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class MapViewportControllerTest {

@Autowired
private MockMvc mockMvc;

@Test
void getViewportIsPublicAndReturnsSigunguModeAtWideZoom() throws Exception {
mockMvc.perform(baseViewportRequest().param("zoom", "0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("SIGUNGU_AVG"));

mockMvc.perform(baseViewportRequest().param("zoom", "11"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("OK"))
.andExpect(jsonPath("$.data.mode").value("SIGUNGU_AVG"))
.andExpect(jsonPath("$.data.totalCount").value(0))
.andExpect(jsonPath("$.data.items", hasSize(0)));
}

@Test
void getViewportReturnsDongModeAtMiddleZoom() throws Exception {
mockMvc.perform(baseViewportRequest().param("zoom", "12"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("DONG_AVG"));

mockMvc.perform(baseViewportRequest().param("zoom", "13"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("DONG_AVG"));
}

@Test
void getViewportReturnsClusterModeBeforeDetailedMarkers() throws Exception {
mockMvc.perform(baseViewportRequest().param("zoom", "14"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("PROPERTY_CLUSTER"));

mockMvc.perform(baseViewportRequest().param("zoom", "15"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("PROPERTY_CLUSTER"));
}

@Test
void getViewportReturnsPropertyMarkerModeAtDetailedZoom() throws Exception {
mockMvc.perform(baseViewportRequest().param("zoom", "16"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("PROPERTY_MARKER"));

mockMvc.perform(baseViewportRequest().param("zoom", "21"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.mode").value("PROPERTY_MARKER"));
}

@Test
void getViewportRejectsInvalidBounds() throws Exception {
mockMvc.perform(get("/api/v1/map/viewport")
.param("west", "127.00")
.param("east", "126.00")
.param("south", "37.46")
.param("north", "37.48")
.param("zoom", "12"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_BOUNDS"))
.andExpect(jsonPath("$.status").value(400));
}

@Test
void getViewportRejectsMissingZoom() throws Exception {
mockMvc.perform(baseViewportRequest())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_REQUEST"))
.andExpect(jsonPath("$.status").value(400));
}

@Test
void getViewportRejectsOutOfRangeZoom() throws Exception {
mockMvc.perform(baseViewportRequest().param("zoom", "22"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_REQUEST"))
.andExpect(jsonPath("$.status").value(400));
}

private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder baseViewportRequest() {
return get("/api/v1/map/viewport")
.param("west", "126.93")
.param("east", "126.94")
.param("south", "37.46")
.param("north", "37.48");
}
}
2 changes: 2 additions & 0 deletions backend/src/test/resources/.env.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
filename=.env.test
ignoreIfMissing=true
1 change: 1 addition & 0 deletions backend/src/test/resources/application-test.properties
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ ai.agent.base-url=http://localhost:8000
ai.agent.internal-api-key=change-me
ai.agent.connect-timeout-ms=2000
ai.agent.read-timeout-ms=10000
app.cors.allowed-origins=http://localhost:5173,http://127.0.0.1:5173
Loading