diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java index 30ac0106..155959d8 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java @@ -7,7 +7,6 @@ import org.sejongisc.backend.attendance.dto.AttendanceResponse; import org.sejongisc.backend.attendance.service.AttendanceService; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.sejongisc.backend.user.entity.User; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -40,8 +39,7 @@ public ResponseEntity checkIn( log.info("출석 체크인 요청: 사용자={}, 코드={}", userDetails.getName(), request.getCode()); - User user = convertToUser(userDetails); - AttendanceResponse response = attendanceService.checkIn(sessionId, request, user); + AttendanceResponse response = attendanceService.checkIn(sessionId, request, userDetails.getUserId()); log.info("출석 체크인 완료: 사용자={}, 상태={}", userDetails.getName(), response.getAttendanceStatus()); @@ -58,7 +56,7 @@ public ResponseEntity checkIn( public ResponseEntity> getAttendancesBySession(@PathVariable UUID sessionId) { log.info("세션별 출석 목록 조회: 세션ID={}", sessionId); - List attendances = attendanceService.getAttendanceBySession(sessionId); + List attendances = attendanceService.getAttendancesBySession(sessionId); return ResponseEntity.ok(attendances); } @@ -73,8 +71,7 @@ public ResponseEntity> getMyAttendances( @AuthenticationPrincipal CustomUserDetails userDetails) { log.info("내 출석 기록 조회: 사용자={}", userDetails.getName()); - User user = convertToUser(userDetails); - List attendances = attendanceService.getAttendancesByUser(user); + List attendances = attendanceService.getAttendancesByUser(userDetails.getUserId()); return ResponseEntity.ok(attendances); } @@ -95,24 +92,10 @@ public ResponseEntity updateAttendanceStatus( log.info("출석 상태 수정: 세션ID={}, 멤버ID={}, 새로운상태={}, 관리자={}", sessionId, memberId, status, userDetails.getName()); - User user = convertToUser(userDetails); - AttendanceResponse response = attendanceService.updateAttendanceStatus(sessionId, memberId, status, reason, user); + AttendanceResponse response = attendanceService.updateAttendanceStatus(sessionId, memberId, status, reason, userDetails.getUserId()); log.info("출석 상태 수정 완료: 세션ID={}, 멤버ID={}, 상태={}", sessionId, memberId, response.getAttendanceStatus()); return ResponseEntity.ok(response); } - - - private User convertToUser(CustomUserDetails userDetails) { - return User.builder() - .userId(userDetails.getUserId()) - .name(userDetails.getName()) - .email(userDetails.getEmail()) - .passwordHash(userDetails.getPassword()) - .phoneNumber(userDetails.getPhoneNumber()) - .role(userDetails.getRole()) - .point(userDetails.getPoint()) - .build(); - } } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java index dca050d0..ad8c8d78 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java @@ -3,7 +3,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.attendance.dto.AttendanceRequest; import org.sejongisc.backend.attendance.dto.AttendanceSessionRequest; import org.sejongisc.backend.attendance.dto.AttendanceSessionResponse; import org.sejongisc.backend.attendance.entity.SessionStatus; @@ -50,7 +49,7 @@ public ResponseEntity createSession(@Valid @RequestBo */ @GetMapping("/{sessionId}") public ResponseEntity getSession(@PathVariable UUID sessionId) { - log.info("춣석 세션 조회: 세션ID={}", sessionId); + log.info("출석 세션 조회: 세션ID={}", sessionId); AttendanceSessionResponse response = attendanceSessionService.getSessionById(sessionId); @@ -189,7 +188,7 @@ public ResponseEntity closeSession(@PathVariable UUID sessionId) { attendanceSessionService.closeSession(sessionId); - log.info("출석 세션 종료 오나료: 세션ID={}", sessionId); + log.info("출석 세션 종료 완료: 세션ID={}", sessionId); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java index 551d5bf6..f5a442bc 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java @@ -10,6 +10,7 @@ import org.sejongisc.backend.attendance.entity.Location; import org.sejongisc.backend.attendance.repository.AttendanceRepository; import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; +import org.sejongisc.backend.user.dao.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,17 +28,21 @@ public class AttendanceService { private final AttendanceRepository attendanceRepository; private final AttendanceSessionRepository attendanceSessionRepository; + private final UserRepository userRepository; /** * 출석 체크인 처리 - * - 코드 유효성 및 중복 춣석 방지 + * - 코드 유효성 및 중복 출석 방지 * - GPS 위치 및 반경 검증 * - 시간 윈도우 검증 및 지각 판별 */ - public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, User user) { + public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다: " + userId)); log.info("출석 체크인 시작: 사용자={}, 세션ID={}, 코드={}", user.getName(), sessionId, request.getCode()); + // 세션ID로 세션 조회 AttendanceSession session = attendanceSessionRepository.findById(sessionId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); @@ -54,9 +59,9 @@ public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, Use // 위치 정보가 있는 세션에 대해서만 사용자 위치 생성 및 검증 Location userLocation = null; if (session.getLocation() != null) { - if (request.getLatitude() == null || request.getLongitude() == null) { + if (request.getLatitude() == null || request.getLongitude() == null) { throw new IllegalArgumentException("위치 기반 출석에는 위도와 경도가 필요합니다"); - } + } userLocation = Location.builder() .lat(request.getLatitude()) @@ -79,7 +84,9 @@ public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, Use throw new IllegalStateException("출석 시간이 종료되었습니다"); } - AttendanceStatus status = now.isAfter(session.getStartsAt()) ? + // 시작 후 5분 이내는 정상 출석, 이후는 지각 + LocalDateTime lateThreshold = session.getStartsAt().plusMinutes(5); + AttendanceStatus status = now.isAfter(lateThreshold) ? AttendanceStatus.LATE : AttendanceStatus.PRESENT; Attendance attendance = Attendance.builder() @@ -106,7 +113,7 @@ public AttendanceResponse checkIn(UUID sessionId, AttendanceRequest request, Use * - 출석 시간 순으로 정렬 */ @Transactional(readOnly = true) - public List getAttendanceBySession(UUID sessionId) { + public List getAttendancesBySession(UUID sessionId) { AttendanceSession session = attendanceSessionRepository.findById(sessionId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다: " + sessionId)); @@ -123,7 +130,9 @@ public List getAttendanceBySession(UUID sessionId) { * - 최신 순으로 정렬 */ @Transactional(readOnly = true) - public List getAttendancesByUser(User user) { + public List getAttendancesByUser(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다: " + userId)); List attendances = attendanceRepository.findByUserOrderByCheckedAtDesc(user); return attendances.stream() @@ -136,7 +145,9 @@ public List getAttendancesByUser(User user) { * - PRESENT/LATE/ABSENT 등으로 상태 변경 * - 수정 사유 기록 및 로그 남기기 */ - public AttendanceResponse updateAttendanceStatus(UUID sessionId, UUID memberId, String status, String reason, User adminUser) { + public AttendanceResponse updateAttendanceStatus(UUID sessionId, UUID memberId, String status, String reason, UUID adminId) { + User adminUser = userRepository.findById(adminId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 관리자입니다: " + adminId)); log.info("출석 상태 수정 시작: 세션ID={}, 멤버ID={}, 새로운상태={}, 관리자={}", sessionId, memberId, status, adminUser.getName()); // 세션 존재 확인 diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java index 0ec734aa..63c5dd32 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java @@ -121,7 +121,7 @@ public List getSessionsByTag(String tag) { /** * 상태별 세션 목록 조회 - * - UPCOMING/OPEN/CLOSED 상태펼 필터링 + * - UPCOMING/OPEN/CLOSED 상태별 필터링 */ @Transactional(readOnly = true) public List getSessionsByStatus(SessionStatus status) { @@ -159,14 +159,14 @@ public List getActiveSessions() { return allSessions.stream() .filter(session -> { - if (session.getStatus() != SessionStatus.OPEN) { - return false; - } - LocalDateTime endTime = session.getStartsAt().plusSeconds(session.getWindowSeconds()); - return !now.isBefore(session.getStartsAt()) && now.isBefore(endTime); - }) - .map(this::convertToResponse) - .collect(Collectors.toList()); + if (session.getStatus() != SessionStatus.OPEN) { + return false; + } + LocalDateTime endTime = session.getStartsAt().plusSeconds(session.getWindowSeconds()); + return !now.isBefore(session.getStartsAt()) && now.isBefore(endTime); + }) + .map(this::convertToResponse) + .collect(Collectors.toList()); } /** @@ -237,6 +237,7 @@ public void activateSession(UUID sessionId) { session = session.toBuilder() .status(SessionStatus.OPEN) + .startsAt(LocalDateTime.now()) .build(); attendanceSessionRepository.save(session); @@ -265,7 +266,7 @@ public void closeSession(UUID sessionId) { } /** - * 중복되지 않은 6자리 코드 생성 + * 중복되지 않는 6자리 코드 생성 * - DB에서 중복 검사 후 유니크 코드 리턴 */ private String generateUniqueCode() { diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java new file mode 100644 index 00000000..978dce3c --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java @@ -0,0 +1,310 @@ +package org.sejongisc.backend.attendance.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sejongisc.backend.attendance.dto.AttendanceRequest; +import org.sejongisc.backend.attendance.dto.AttendanceResponse; +import org.sejongisc.backend.attendance.entity.Attendance; +import org.sejongisc.backend.attendance.entity.AttendanceStatus; +import org.sejongisc.backend.attendance.service.AttendanceService; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +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.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AttendanceController.class) +@Import(TestSecurityConfig.class) +public class AttendanceControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockitoBean + private AttendanceService attendanceService; + @MockitoBean + private JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @Test + @DisplayName("출석 체크인 성공") + @WithMockUser + void checkIn_success() throws Exception { + //given + AttendanceRequest request = AttendanceRequest.builder() + .code("123456") + .latitude(37.5665) + .longitude(126.9780) + .note("정상 춣석") + .deviceInfo("iphone 14") + .build(); + + User user = User.builder() + .userId(UUID.randomUUID()) + .name("오찬혁") + .email("oh@example.com") + .role(Role.TEAM_MEMBER) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(user); + + AttendanceResponse response = AttendanceResponse.builder() + .attendanceId(UUID.randomUUID()) + .userId(user.getUserId()) + .userName("오찬혁") + .attendanceSessionId(UUID.randomUUID()) + .attendanceStatus(AttendanceStatus.PRESENT) + .checkedAt(LocalDateTime.now()) + .awardedPoints(10) + .note("정상 출석") + .deviceInfo("iphone 14") + .isLate(false) + .build(); + + when(attendanceService.checkIn(any(UUID.class), any(AttendanceRequest.class), eq(user.getUserId()))).thenReturn(response); + + //then + UUID sessionId = UUID.randomUUID(); + mockMvc.perform(post("/api/attendance/sessions/{sessionId}/check-in", sessionId) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.userName").value("오찬혁")) + .andExpect(jsonPath("$.attendanceStatus").value("PRESENT")) + .andExpect(jsonPath("$.awardedPoints").value(10)) + .andExpect(jsonPath("$.note").value("정상 출석")) + .andExpect(jsonPath("$.deviceInfo").value("iphone 14")) + .andExpect(jsonPath("$.late").value(false)); + } + + @Test + @DisplayName("출석 체크인 실패: 유효성 검증 오류") + @WithMockUser + void checkIn_fail_validation() throws Exception { + //given + AttendanceRequest request = AttendanceRequest.builder() + .code("12345") + .latitude(91.0) + .longitude(181.0) + .build(); + + User user = User.builder() + .userId(UUID.randomUUID()) + .name("오찬혁") + .email("oh@example.com") + .role(Role.TEAM_MEMBER) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(user); + + //then + UUID sessionId = UUID.randomUUID(); + mockMvc.perform(post("/api/attendance/sessions/{sessionId}/check-in", sessionId) + .with(user(userDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()); + } + + @Test + @DisplayName("내 출석 기록 조회 성공") + @WithMockUser + void getMyAttendances_success() throws Exception { + //given + User user = User.builder() + .userId(UUID.randomUUID()) + .name("오찬혁") + .email("oh@example.com") + .role(Role.TEAM_MEMBER) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(user); + + List responses = Arrays.asList( + AttendanceResponse.builder() + .attendanceId(UUID.randomUUID()) + .userId(user.getUserId()) + .userName("오찬혁") + .attendanceSessionId(UUID.randomUUID()) + .attendanceStatus(AttendanceStatus.PRESENT) + .checkedAt(LocalDateTime.now().minusDays(1)) + .awardedPoints(10) + .build(), + AttendanceResponse.builder() + .attendanceId(UUID.randomUUID()) + .userId(user.getUserId()) + .userName("오찬혁") + .attendanceSessionId(UUID.randomUUID()) + .attendanceStatus(AttendanceStatus.LATE) + .checkedAt(LocalDateTime.now()) + .awardedPoints(5) + .build() + ); + + when(attendanceService.getAttendancesByUser(eq(user.getUserId()))).thenReturn(responses); + + //then + mockMvc.perform(get("/api/attendance/history") + .with(user(userDetails))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].attendanceStatus").value("PRESENT")) + .andExpect(jsonPath("$[0].awardedPoints").value(10)) + .andExpect(jsonPath("$[1].attendanceStatus").value("LATE")) + .andExpect(jsonPath("$[1].awardedPoints").value(5)); + } + + @Test + @DisplayName("세션별 출석 목록 조회 성공 (관리자)") + @WithMockUser(roles = "PRESIDENT") + void getAttendancesBySession_success() throws Exception { + //given + UUID sessionId = UUID.randomUUID(); + List responses = Arrays.asList( + AttendanceResponse.builder() + .attendanceId(UUID.randomUUID()) + .userId(UUID.randomUUID()) + .userName("오찬혁") + .attendanceSessionId(sessionId) + .attendanceStatus(AttendanceStatus.PRESENT) + .checkedAt(LocalDateTime.now()) + .awardedPoints(10) + .build(), + AttendanceResponse.builder() + .attendanceId(UUID.randomUUID()) + .userId(UUID.randomUUID()) + .userName("김찬혁") + .attendanceSessionId(sessionId) + .attendanceStatus(AttendanceStatus.LATE) + .checkedAt(LocalDateTime.now()) + .awardedPoints(5) + .build() + ); + + when(attendanceService.getAttendancesBySession(sessionId)).thenReturn(responses); + + //then + mockMvc.perform(get("/api/attendance/sessions/{sessionId}/attendances", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].userName").value("오찬혁")) + .andExpect(jsonPath("$[0].attendanceStatus").value("PRESENT")) + .andExpect(jsonPath("$[1].userName").value("김찬혁")) + .andExpect(jsonPath("$[1].attendanceStatus").value("LATE")); + } + + @Test + @DisplayName("세션별 출석 목록 조회 실패: 권한 없음") + void getAttendancesBySession_fail_noPermission() throws Exception { + //given + UUID sessionId = UUID.randomUUID(); + + User teamMemberUser = User.builder() + .userId(UUID.randomUUID()) + .name("멤버") + .email("member@example.com") + .role(Role.TEAM_MEMBER) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(teamMemberUser); + + //then + mockMvc.perform(get("/api/attendance/sessions/{sessionId}/attendances", sessionId) + .with(user(userDetails))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("출석 상태 수정 성공 (관리자)") + void updateAttendanceStatus_success() throws Exception { + //given + UUID attendanceId = UUID.randomUUID(); + String status = "PRESENT"; + String reason = "관리자 수정"; + + User adminUser = User.builder() + .userId(UUID.randomUUID()) + .name("관리자") + .email("admin@example.com") + .role(Role.PRESIDENT) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(adminUser); + + AttendanceResponse response = AttendanceResponse.builder() + .attendanceId(attendanceId) + .userId(UUID.randomUUID()) + .userName("오찬혁") + .attendanceSessionId(UUID.randomUUID()) + .attendanceStatus(AttendanceStatus.PRESENT) + .checkedAt(LocalDateTime.now()) + .awardedPoints(10) + .note(reason) + .build(); + + when(attendanceService.updateAttendanceStatus( + any(UUID.class), eq(attendanceId), eq(status), eq(reason), eq(adminUser.getUserId()) + )).thenReturn(response); + + //then + UUID sessionId = UUID.randomUUID(); + mockMvc.perform(post("/api/attendance/sessions/{sessionId}/attendances/{memberId}", sessionId, attendanceId) + .with(authentication(new UsernamePasswordAuthenticationToken( + userDetails, null, Collections.singletonList(new SimpleGrantedAuthority("ROLE_PRESIDENT")) + ))) + .param("status", status) + .param("reason", reason)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.attendanceStatus").value("PRESENT")) + .andExpect(jsonPath("$.note").value(reason)); + } + + @Test + @DisplayName("출석 상태 수정 실패: 권한 없음") + void updateAttendanceStatus_fail_noPermission() throws Exception { + //given + UUID attendanceId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + + User teamMemberUser = User.builder() + .userId(UUID.randomUUID()) + .name("member@example.com") + .role(Role.TEAM_MEMBER) + .build(); + + CustomUserDetails userDetails = new CustomUserDetails(teamMemberUser); + + //then + mockMvc.perform(post("/api/attendance/sessions/{sessionId}/attendances/{memberId}", sessionId, attendanceId) + .with(user(userDetails)) + .param("status", "PRESENT") + .param("reason", "사유")) + .andExpect(status().isForbidden()); + + } +} diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceSessionControllerTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceSessionControllerTest.java new file mode 100644 index 00000000..d6a9d89c --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceSessionControllerTest.java @@ -0,0 +1,429 @@ +package org.sejongisc.backend.attendance.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sejongisc.backend.attendance.dto.AttendanceSessionRequest; +import org.sejongisc.backend.attendance.dto.AttendanceSessionResponse; +import org.sejongisc.backend.attendance.entity.SessionStatus; +import org.sejongisc.backend.attendance.entity.SessionVisibility; +import org.sejongisc.backend.attendance.service.AttendanceSessionService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +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.Arrays; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AttendanceSessionController.class) +@Import(TestSecurityConfig.class) +public class AttendanceSessionControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockitoBean + private AttendanceSessionService attendanceSessionService; + @MockitoBean + private JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @Test + @DisplayName("출석 세션 생성 성공 (관리자)") + @WithMockUser(roles = "PRESIDENT") + void createSession_success() throws Exception { + //given + AttendanceSessionRequest request = AttendanceSessionRequest.builder() + .title("세투연 정규세션") + .tag("금융IT") + .startsAt(LocalDateTime.now().plusHours(1)) + .windowSeconds(1800) + .rewardPoints(10) + .latitude(37.5665) + .longitude(126.9780) + .radiusMeters(100) + .visibility(SessionVisibility.PUBLIC) + .build(); + + AttendanceSessionResponse response = AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("세투연 정규 세션") + .tag("금융IT") + .startsAt(request.getStartsAt()) + .windowSeconds(1800) + .code("123456") + .rewardPoints(10) + .latitude(37.5665) + .longitude(126.9780) + .radiusMeters(100) + .visibility(SessionVisibility.PUBLIC) + .status(SessionStatus.UPCOMING) + .participantCount(0) + .build(); + + when(attendanceSessionService.createSession(any(AttendanceSessionRequest.class))).thenReturn(response); + + //then + mockMvc.perform(post("/api/attendance/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.title").value("세투연 정규 세션")) + .andExpect(jsonPath("$.tag").value("금융IT")) + .andExpect(jsonPath("$.code").value("123456")) + .andExpect(jsonPath("$.rewardPoints").value(10)) + .andExpect(jsonPath("$.latitude").value(37.5665)) + .andExpect(jsonPath("$.longitude").value(126.9780)) + .andExpect(jsonPath("$.radiusMeters").value(100)) + .andExpect(jsonPath("$.visibility").value("PUBLIC")) + .andExpect(jsonPath("$.status").value("UPCOMING")) + .andExpect(jsonPath("$.participantCount").value(0)); + } + + @Test + @DisplayName("출석 세션 생성 실패: 권한 없음") + @WithMockUser(roles = "TEAM_MEMBER") + void createSession_fail_noPermission() throws Exception { + //given + AttendanceSessionRequest request = AttendanceSessionRequest.builder() + .title("세투연 정규 세션") + .startsAt(LocalDateTime.now().plusHours(1)) + .windowSeconds(1800) + .build(); + + //then + mockMvc.perform(post("/api/attendance/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + + } + + @Test + @DisplayName("출석 세션 생성 실패: 유혀성 검증 오류") + @WithMockUser(roles = "PRESIDENT") + void createSession_fail_validation() throws Exception { + //given + AttendanceSessionRequest request = AttendanceSessionRequest.builder() + .title("") + .startsAt(LocalDateTime.now().minusHours(1)) + .windowSeconds(100) + .latitude(91.0) + .build(); + + //then + mockMvc.perform(post("/api/attendance/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()); + } + + @Test + @DisplayName("출석 코드로 세션 조회 성공") + @WithMockUser + void getSessionByCode_success() throws Exception { + //given + String code = "123456"; + AttendanceSessionResponse response = AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("세투연 정규 세션") + .code(code) + .startsAt(LocalDateTime.now().plusMinutes(30)) + .windowSeconds(1800) + .status(SessionStatus.UPCOMING) + .participantCount(5) + .remainingSeconds(1800L) + .checkInAvailable(false) + .build(); + + when(attendanceSessionService.getSessionByCode(code)).thenReturn(response); + + //then + mockMvc.perform(get("/api/attendance/sessions/code/{code}", code)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(code)) + .andExpect(jsonPath("$.title").value("세투연 정규 세션")) + .andExpect(jsonPath("$.participantCount").value(5)) + .andExpect(jsonPath("$.remainingSeconds").value(1800)) + .andExpect(jsonPath("$.checkInAvailable").value(false)); + } + + @Test + @DisplayName("세션 상세 조회 성공") + @WithMockUser + void getSession_success() throws Exception { + //given + UUID sessionId = UUID.randomUUID(); + AttendanceSessionResponse response = AttendanceSessionResponse.builder() + .attendanceSessionId(sessionId) + .title("세투연 정규 세션") + .code("123456") + .startsAt(LocalDateTime.now().plusHours(1)) + .windowSeconds(1800) + .status(SessionStatus.UPCOMING) + .participantCount(0) + .build(); + + when(attendanceSessionService.getSessionById(sessionId)).thenReturn(response); + + //then + mockMvc.perform(get("/api/attendance/sessions/{sessionId}", sessionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.attendanceSessionId").value(sessionId.toString())) + .andExpect(jsonPath("$.title").value("세투연 정규 세션")) + .andExpect(jsonPath("$.code").value("123456")); + } + + @Test + @DisplayName("공개 세션 목록 조회 성공") + @WithMockUser + void getPublicSessions_success() throws Exception { + //given + List responses = Arrays.asList( + AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("정규 세션 1") + .code("111111") + .status(SessionStatus.UPCOMING) + .visibility(SessionVisibility.PUBLIC) + .participantCount(10) + .build(), + AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("정규 세션 2") + .code("222222") + .status(SessionStatus.OPEN) + .visibility(SessionVisibility.PUBLIC) + .participantCount(5) + .build() + ); + + when(attendanceSessionService.getPublicSessions()).thenReturn(responses); + + //then + mockMvc.perform(get("/api/attendance/sessions/public")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].title").value("정규 세션 1")) + .andExpect(jsonPath("$[0].participantCount").value(10)) + .andExpect(jsonPath("$[1].title").value("정규 세션 2")) + .andExpect(jsonPath("$[1].participantCount").value(5)); + } + + @Test + @DisplayName("활성 세션 목록 조회 성공") + @WithMockUser + void getActivateSessions_success() throws Exception { + //given + List responses = Arrays.asList( + AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("활성 세션") + .code("111111") + .status(SessionStatus.OPEN) + .checkInAvailable(true) + .remainingSeconds(900L) + .participantCount(8) + .build() + ); + + when(attendanceSessionService.getActiveSessions()).thenReturn(responses); + + //then + mockMvc.perform(get("/api/attendance/sessions/active")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].title").value("활성 세션")) + .andExpect(jsonPath("$[0].status").value("OPEN")) + .andExpect(jsonPath("$[0].checkInAvailable").value(true)) + .andExpect(jsonPath("$[0].remainingSeconds").value(900)) + .andExpect(jsonPath("$[0].participantCount").value(8)); + } + + @Test + @DisplayName("태그별 세션 목록 조회 성공") + @WithMockUser + void getSessionByTag_success() throws Exception { + //given + String tag = "금융IT"; + List responses = Arrays.asList( + AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("금융IT 정규 세션 1") + .tag(tag) + .code("111111") + .participantCount(16) + .build(), + AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("금융IT 정규 세션 2") + .tag(tag) + .code("222222") + .participantCount(14) + .build() + ); + + when(attendanceSessionService.getSessionsByTag(tag)).thenReturn(responses); + + //then + mockMvc.perform(get("/api/attendance/sessions/tag/{tag}", tag)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].tag").value(tag)) + .andExpect(jsonPath("$[1].tag").value(tag)); + } + + @Test + @DisplayName("세션 수정 성공 (관리자)") + @WithMockUser(roles = "PRESIDENT") + void updateSession_success() throws Exception { + //given + UUID sessionId = UUID.randomUUID(); + AttendanceSessionRequest request = AttendanceSessionRequest.builder() + .title("수정된 제목") + .tag("수정된 태그") + .startsAt(LocalDateTime.now().plusHours(2)) + .windowSeconds(3600) + .rewardPoints(10) + .visibility(SessionVisibility.PRIVATE) + .build(); + + AttendanceSessionResponse response = AttendanceSessionResponse.builder() + .attendanceSessionId(sessionId) + .title("수정된 제목") + .tag("수정된 태그") + .startsAt(request.getStartsAt()) + .windowSeconds(3600) + .code("123456") + .rewardPoints(10) + .visibility(SessionVisibility.PRIVATE) + .status(SessionStatus.UPCOMING) + .participantCount(0) + .build(); + + when(attendanceSessionService.updateSession(eq(sessionId), any(AttendanceSessionRequest.class))).thenReturn(response); + + //then + mockMvc.perform(put("/api/attendance/sessions/{sessionId}", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("수정된 제목")) + .andExpect(jsonPath("$.tag").value("수정된 태그")) + .andExpect(jsonPath("$.rewardPoints").value(10)) + .andExpect(jsonPath("$.visibility").value("PRIVATE")); + } + + @Test + @DisplayName("세션 활성화 성공") + @WithMockUser(roles = "PRESIDENT") + void activateSession_success() throws Exception { + //given + UUID sessionId = UUID.randomUUID(); + doNothing().when(attendanceSessionService).activateSession(sessionId); + + //then + mockMvc.perform(post("/api/attendance/sessions/{sessionId}/activate", sessionId)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("세션 종료 성공 (관리자)") + @WithMockUser(roles = "PRESIDENT") + void closeSession_success() throws Exception { + //given + UUID sessionId = UUID.randomUUID(); + doNothing().when(attendanceSessionService).closeSession(sessionId); + + //then + mockMvc.perform(post("/api/attendance/sessions/{sessionId}/close", sessionId)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("세션 삭제 성공 (관리자)") + @WithMockUser(roles = "PRESIDENT") + void deleteSession_success() throws Exception { + //given + UUID sessionId = UUID.randomUUID(); + doNothing().when(attendanceSessionService).deleteSession(sessionId); + + //then + mockMvc.perform(delete("/api/attendance/sessions/{sessionId}", sessionId)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("상태별 세션 목록 조회 성공 (관리자)") + @WithMockUser(roles = "PRESIDENT") + void getSessionByStatus_success() throws Exception { + //given + SessionStatus status = SessionStatus.OPEN; + List responses = Arrays.asList( + AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("진행중인 세션 1") + .status(status) + .participantCount(8) + .build(), + AttendanceSessionResponse.builder() + .attendanceSessionId(UUID.randomUUID()) + .title("진행중인 세션 2") + .status(status) + .participantCount(14) + .build() + ); + + when(attendanceSessionService.getSessionsByStatus(status)).thenReturn(responses); + + //then + mockMvc.perform(get("/api/attendance/sessions/status/{status}", status)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].status").value("OPEN")) + .andExpect(jsonPath("$[1].status").value("OPEN")); + } + + @Test + @DisplayName("관리자 전용 기능 접근 실패: 권한 없음") + @WithMockUser(roles = "TEAM_MEMBER") + void adminOnlyEndpoints_fail_noPermission() throws Exception { + UUID sessionId = UUID.randomUUID(); + + // 세션 수정 + mockMvc.perform(put("/api/attendance/sessions/{sessionId}", sessionId) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + + // 세션 활성화 + mockMvc.perform(post("/api/attendance/sessions/{sessionId}/activate", sessionId)) + .andExpect(status().isForbidden()); + + // 세션 종료 + mockMvc.perform(post("/api/attendance/sessions/{sessionId}/close", sessionId)) + .andExpect(status().isForbidden()); + + // 세션 삭제 + mockMvc.perform(delete("/api/attendance/sessions/{sessionId}", sessionId)) + .andExpect(status().isForbidden()); + + // 상태별 조회 + mockMvc.perform(get("/api/attendance/sessions/status/OPEN")) + .andExpect(status().isForbidden()); + } +} diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/controller/TestSecurityConfig.java b/backend/src/test/java/org/sejongisc/backend/attendance/controller/TestSecurityConfig.java new file mode 100644 index 00000000..306196cf --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/attendance/controller/TestSecurityConfig.java @@ -0,0 +1,32 @@ +package org.sejongisc.backend.attendance.controller; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@TestConfiguration +@EnableWebSecurity +public class TestSecurityConfig { + + @Bean + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + // 관리자 전용 엔드포인트들 (구체적인 패턴부터 먼저) + .requestMatchers(HttpMethod.POST, "/api/attendance/sessions/*/attendances/*").hasAnyRole("PRESIDENT", "VICE_PRESIDENT") + .requestMatchers(HttpMethod.GET, "/api/attendance/sessions/*/attendances").hasAnyRole("PRESIDENT", "VICE_PRESIDENT") + .requestMatchers(HttpMethod.POST, "/api/attendance/sessions").hasAnyRole("PRESIDENT", "VICE_PRESIDENT") + .requestMatchers(HttpMethod.PUT, "/api/attendance/sessions/*").hasAnyRole("PRESIDENT", "VICE_PRESIDENT") + .requestMatchers(HttpMethod.DELETE, "/api/attendance/sessions/*").hasAnyRole("PRESIDENT", "VICE_PRESIDENT") + .requestMatchers("/api/attendance/sessions/*/activate").hasAnyRole("PRESIDENT", "VICE_PRESIDENT") + .requestMatchers("/api/attendance/sessions/*/close").hasAnyRole("PRESIDENT", "VICE_PRESIDENT") + .requestMatchers("/api/attendance/sessions/status/*").hasAnyRole("PRESIDENT", "VICE_PRESIDENT") + // 나머지는 모든 인증된 사용자 + .anyRequest().authenticated() + ).build(); + } +} diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java index 6fa1108c..5dcda5d5 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java @@ -10,6 +10,7 @@ import org.sejongisc.backend.attendance.entity.*; import org.sejongisc.backend.attendance.repository.AttendanceRepository; import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; +import org.sejongisc.backend.user.dao.UserRepository; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; @@ -28,6 +29,8 @@ public class AttendanceServiceTest { private AttendanceRepository attendanceRepository; @Mock private AttendanceSessionRepository attendanceSessionRepository; + @Mock + private UserRepository userRepository; @InjectMocks private AttendanceService attendanceService; @@ -82,12 +85,13 @@ public class AttendanceServiceTest { .deviceInfo("IPhone 14") .build(); + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); when(attendanceRepository.existsByAttendanceSessionAndUser(session, user)).thenReturn(false); when(attendanceRepository.save(any(Attendance.class))).thenReturn(savedAttendance); //when - AttendanceResponse response = attendanceService.checkIn(sessionId, request, user); + AttendanceResponse response = attendanceService.checkIn(sessionId, request, user.getUserId()); //then assertAll( @@ -117,10 +121,11 @@ public class AttendanceServiceTest { .build(); UUID sessionId = UUID.randomUUID(); + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.empty()); // then - assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user)) + assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user.getUserId())) .isInstanceOf(IllegalArgumentException.class) .hasMessage("존재하지 않는 세션입니다: " + sessionId); @@ -149,11 +154,12 @@ public class AttendanceServiceTest { .code(code) .build(); + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); when(attendanceRepository.existsByAttendanceSessionAndUser(session, user)).thenReturn(true); //then - assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user)) + assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user.getUserId())) .isInstanceOf(IllegalStateException.class) .hasMessage("이미 출석 체크인한 세션입니다"); @@ -188,11 +194,12 @@ public class AttendanceServiceTest { .location(sessionLocation) .build(); + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); when(attendanceRepository.existsByAttendanceSessionAndUser(session, user)).thenReturn(false); //then - assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user)) + assertThatThrownBy(() -> attendanceService.checkIn(sessionId, request, user.getUserId())) .isInstanceOf(IllegalArgumentException.class) .hasMessage("출석 허용 범위를 벗어났습니다"); @@ -232,7 +239,7 @@ public class AttendanceServiceTest { when(attendanceRepository.findByAttendanceSessionOrderByCheckedAtAsc(session)).thenReturn(attendances); //when - List response = attendanceService.getAttendanceBySession(sessionId); + List response = attendanceService.getAttendancesBySession(sessionId); //then assertAll( @@ -276,13 +283,14 @@ public class AttendanceServiceTest { .checkedAt(LocalDateTime.now()) .build(); + when(userRepository.findById(adminUser.getUserId())).thenReturn(Optional.of(adminUser)); when(attendanceSessionRepository.findById(sessionId)).thenReturn(Optional.of(session)); when(attendanceRepository.findByAttendanceSessionAndUser_UserId(session, memberId)).thenReturn(Optional.of(attendance)); when(attendanceRepository.save(any(Attendance.class))).thenReturn(attendance); //when AttendanceResponse response = attendanceService.updateAttendanceStatus( - sessionId, memberId, newStatus, reason, adminUser); + sessionId, memberId, newStatus, reason, adminUser.getUserId()); //then assertAll( @@ -328,10 +336,11 @@ public class AttendanceServiceTest { .build() ); + when(userRepository.findById(user.getUserId())).thenReturn(Optional.of(user)); when(attendanceRepository.findByUserOrderByCheckedAtDesc(user)).thenReturn(attendances); //when - List responses = attendanceService.getAttendancesByUser(user); + List responses = attendanceService.getAttendancesByUser(user.getUserId()); //then assertAll(