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
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,21 @@ public CommonResponse<DispatchSummaryRes> getDispatchSummary() {
description = """
최근 배차 이벤트를 시간순으로 조회합니다.

**피드 타입:**
- `open`: 배차 등록
- `assigned`: 배차 할당 (기사 배정)
**반환되는 배차 상태 (4가지):**
- `open`: 배차 등록 (대기 중)
- `assigned`: 배차 할당 (기사 배정 완료)
- `completed`: 운송 완료
- `canceled`: 배차 취소

**제외되는 상태:**
- `HOLD`: 자동배차 진행 중 상태는 요구사항에 따라 피드에서 제외됩니다.
(HOLD는 임시 상태로, 최대 50초 이내에 OPEN 또는 ASSIGNED로 전환됨)

**특징:**
- HOLD 상태(자동배차 진행 중)는 제외됩니다.
- 현재 로그인한 사무실의 배차만 조회됩니다 (officeId 필터링)
- 최신순 정렬 (createdAt DESC)
- Transporter 정보 포함 (N+1 최적화 적용)
- transporterName은 assigned/completed 타입에만 값이 있고, open/canceled는 null

**응답 예시:**
```json
Expand All @@ -120,6 +125,15 @@ public CommonResponse<DispatchSummaryRes> getDispatchSummary() {
"transporterName": "김철수",
"message": "김철수 기사가 콜 #2024-0001을 배차 받았습니다",
"timestamp": "2024-01-15T10:32:00"
},
{
"id": "feed-02",
"type": "open",
"dispatchId": 124,
"dispatchNumber": "2024-0002",
"transporterName": null,
"message": "배차 #2024-0002가 등록되었습니다",
"timestamp": "2024-01-15T10:30:00"
}
]
}
Expand All @@ -139,9 +153,10 @@ public CommonResponse<List<DispatchFeedRes>> getDispatchFeed(
example = "20",
required = false
)
@RequestParam(required = false, defaultValue = "20") Integer limit
@RequestParam(required = false, defaultValue = "20") Integer limit,
@AuthenticationPrincipal PrincipalDetails user
) {
return CommonResponse.success(officeService.getDispatchFeed(limit));
return CommonResponse.success(officeService.getDispatchFeed(limit, user.getManager()));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,19 +258,23 @@ public void changeTransporterStatus(Long transporterId, TransporterStatus status
/**
* 대시보드 실시간 피드 조회
* @param limit 조회 개수 (기본: 20)
* @param manager 현재 로그인한 관리자
* @return 최근 배차 이벤트 피드 목록
*/
@Transactional(readOnly = true)
public List<DispatchFeedRes> getDispatchFeed(Integer limit) {
public List<DispatchFeedRes> getDispatchFeed(Integer limit, Manager manager) {
// 최근 배차 조회 (Transporter와 Fetch Join으로 N+1 문제 해결, createdAt 기준 내림차순)
List<Dispatch> recentDispatches = dispatchRepository.findAllWithTransporter(
org.springframework.data.domain.PageRequest.of(0, limit,
org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "createdAt"))
).getContent();

// HOLD 상태 제외 후 리스트로 변환
Long officeId = manager.getOffice().getId();

// HOLD 상태 제외 및 현재 사무실 배차만 필터링
List<Dispatch> filteredDispatches = recentDispatches.stream()
.filter(dispatch -> dispatch.getStatus() != StatusType.HOLD)
.filter(dispatch -> dispatch.getOfficeId().equals(officeId))
.toList();

// 각 배차를 피드 DTO로 변환 (연번 부여)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
package com.mobility.api.domain.office.controller;

import com.mobility.api.domain.dispatch.enums.StatusType;
import com.mobility.api.domain.office.dto.response.DispatchFeedRes;
import com.mobility.api.domain.office.entity.Manager;
import com.mobility.api.domain.office.entity.Office;
import com.mobility.api.domain.office.enums.ManagerRole;
import com.mobility.api.domain.office.repository.ManagerRepository;
import com.mobility.api.domain.office.service.OfficeService;
import com.mobility.api.domain.transporter.repository.TransporterRepository;
import com.mobility.api.global.jwt.JwtProvider;
import com.mobility.api.global.security.CustomUserDetailsService;
import com.mobility.api.global.security.PrincipalDetails;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;
import java.util.List;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(OfficeV1Controller.class)
class OfficeV1ControllerTest {

@Autowired
private MockMvc mockMvc;

@MockitoBean
private OfficeService officeService;

@MockitoBean
private ManagerRepository managerRepository;

@MockitoBean
private TransporterRepository transporterRepository;

@MockitoBean
private JwtProvider jwtProvider;

@MockitoBean
private CustomUserDetailsService customUserDetailsService;

@MockitoBean
private JpaMetamodelMappingContext jpaMetamodelMappingContext;

private Manager testManager;
private Office testOffice;
private PrincipalDetails testPrincipalDetails;

@BeforeEach
void setUp() throws Exception {
// Mock Office 생성
testOffice = Office.builder()
.officeName("테스트 사무실")
.officeRegistrationNumber("123-45-67890")
.officeAddress("서울시 강남구")
.officeTelNumber("02-1234-5678")
.build();

// Reflection으로 id 설정
java.lang.reflect.Field officeIdField = Office.class.getDeclaredField("id");
officeIdField.setAccessible(true);
officeIdField.set(testOffice, 1L);

// Mock Manager 생성
testManager = Manager.builder()
.loginId("testmanager")
.password("encodedPassword")
.name("테스트 관리자")
.phone("010-1234-5678")
.email("test@example.com")
.role(ManagerRole.OWNER)
.office(testOffice)
.build();

// Reflection으로 id 설정
java.lang.reflect.Field managerIdField = Manager.class.getDeclaredField("id");
managerIdField.setAccessible(true);
managerIdField.set(testManager, 1L);

// PrincipalDetails 생성
testPrincipalDetails = new PrincipalDetails(testManager);
}

@Test
@DisplayName("[API] 배차 피드 조회 - 성공 (200 OK)")
@WithMockUser
void getDispatchFeed_Success() throws Exception {
// given
Integer limit = 20;

List<DispatchFeedRes> mockFeedList = List.of(
DispatchFeedRes.builder()
.id("feed-01")
.type("assigned")
.dispatchId(101L)
.dispatchNumber("2024-0001")
.transporterName("김철수")
.message("김철수 기사가 콜 #2024-0001을 배차 받았습니다")
.timestamp(LocalDateTime.of(2024, 1, 15, 10, 30, 0))
.build(),
DispatchFeedRes.builder()
.id("feed-02")
.type("open")
.dispatchId(102L)
.dispatchNumber("2024-0002")
.transporterName(null)
.message("배차 #2024-0002가 등록되었습니다")
.timestamp(LocalDateTime.of(2024, 1, 15, 10, 25, 0))
.build(),
DispatchFeedRes.builder()
.id("feed-03")
.type("completed")
.dispatchId(103L)
.dispatchNumber("2024-0003")
.transporterName("이영희")
.message("이영희 기사가 콜 #2024-0003을 완료했습니다")
.timestamp(LocalDateTime.of(2024, 1, 15, 10, 20, 0))
.build()
);

// Stubbing
given(officeService.getDispatchFeed(eq(limit), any(Manager.class)))
.willReturn(mockFeedList);

// when & then
mockMvc.perform(get("/api/v1/office/dispatch/feed")
.param("limit", String.valueOf(limit))
.with(user(testPrincipalDetails))
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())

// CommonResponse 구조 검증
.andExpect(jsonPath("$.statusCode").value(0))
.andExpect(jsonPath("$.message").value("정상 처리 되었습니다."))

// 데이터(List) 검증
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(3))

// 첫 번째 피드 검증 (assigned)
.andExpect(jsonPath("$.data[0].id").value("feed-01"))
.andExpect(jsonPath("$.data[0].type").value("assigned"))
.andExpect(jsonPath("$.data[0].dispatchId").value(101L))
.andExpect(jsonPath("$.data[0].dispatchNumber").value("2024-0001"))
.andExpect(jsonPath("$.data[0].transporterName").value("김철수"))
.andExpect(jsonPath("$.data[0].message").value("김철수 기사가 콜 #2024-0001을 배차 받았습니다"))

// 두 번째 피드 검증 (open)
.andExpect(jsonPath("$.data[1].id").value("feed-02"))
.andExpect(jsonPath("$.data[1].type").value("open"))
.andExpect(jsonPath("$.data[1].transporterName").doesNotExist())

// 세 번째 피드 검증 (completed)
.andExpect(jsonPath("$.data[2].id").value("feed-03"))
.andExpect(jsonPath("$.data[2].type").value("completed"))
.andExpect(jsonPath("$.data[2].transporterName").value("이영희"));
}

@Test
@DisplayName("[API] 배차 피드 조회 - 기본 limit 값 사용 (limit 미지정 시 20)")
@WithMockUser
void getDispatchFeed_DefaultLimit() throws Exception {
// given
List<DispatchFeedRes> mockFeedList = List.of(
DispatchFeedRes.builder()
.id("feed-01")
.type("open")
.dispatchId(101L)
.dispatchNumber("2024-0001")
.transporterName(null)
.message("배차 #2024-0001가 등록되었습니다")
.timestamp(LocalDateTime.now())
.build()
);

// limit이 20으로 호출되어야 함
given(officeService.getDispatchFeed(eq(20), any(Manager.class)))
.willReturn(mockFeedList);

// when & then
mockMvc.perform(get("/api/v1/office/dispatch/feed")
.with(user(testPrincipalDetails))
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(1));
}

@Test
@DisplayName("[API] 배차 피드 조회 - 빈 결과 (배차가 없는 경우)")
@WithMockUser
void getDispatchFeed_EmptyResult() throws Exception {
// given
Integer limit = 20;
List<DispatchFeedRes> emptyList = List.of();

given(officeService.getDispatchFeed(eq(limit), any(Manager.class)))
.willReturn(emptyList);

// when & then
mockMvc.perform(get("/api/v1/office/dispatch/feed")
.param("limit", String.valueOf(limit))
.with(user(testPrincipalDetails))
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value(0))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(0));
}

@Test
@DisplayName("[API] 배차 피드 조회 - 인증 없이 접근 시 실패 (401 Unauthorized)")
void getDispatchFeed_Unauthorized() throws Exception {
// given - 인증 정보 없이 요청

// when & then
mockMvc.perform(get("/api/v1/office/dispatch/feed")
.param("limit", "20")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isUnauthorized());
}

@Test
@DisplayName("[API] 배차 피드 조회 - 커스텀 limit 값 사용 (50)")
@WithMockUser
void getDispatchFeed_CustomLimit() throws Exception {
// given
Integer customLimit = 50;
List<DispatchFeedRes> mockFeedList = List.of(
DispatchFeedRes.builder()
.id("feed-01")
.type("canceled")
.dispatchId(101L)
.dispatchNumber("2024-0001")
.transporterName(null)
.message("배차 #2024-0001이 취소되었습니다")
.timestamp(LocalDateTime.now())
.build()
);

given(officeService.getDispatchFeed(eq(customLimit), any(Manager.class)))
.willReturn(mockFeedList);

// when & then
mockMvc.perform(get("/api/v1/office/dispatch/feed")
.param("limit", String.valueOf(customLimit))
.with(user(testPrincipalDetails))
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1))
.andExpect(jsonPath("$.data[0].type").value("canceled"));
}
}
Loading