package com.sb.web.auth.controller; import com.sb.web.admin.service.AppSettingService; import com.sb.web.auth.dto.MemberResponse; import com.sb.web.auth.dto.SessionUser; import com.sb.web.auth.service.AuthService; import com.sb.web.auth.web.AdminInterceptor; import com.sb.web.auth.web.AuthInterceptor; import com.sb.web.common.config.WebConfig; 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.boot.test.context.TestConfiguration; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; 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.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * 인증 컨트롤러 웹 계층 통합테스트 (@WebMvcTest — DB/Redis 불필요). * 실제 인터셉터(AuthInterceptor)·경로 매핑(WebConfig)을 로드해 와이어링을 검증한다. * 단위테스트가 못 잡은 "보호 경로 누락 → 401 대신 500" 회귀를 이 계층이 잡는다. */ @WebMvcTest(AuthController.class) @Import({WebConfig.class, AuthInterceptor.class, AdminInterceptor.class}) class AuthControllerWebMvcTest { @Autowired MockMvc mvc; @MockitoBean AuthService authService; @MockitoBean AppSettingService appSettingService; @MockitoBean com.sb.web.point.PointService pointService; /** * @MapperScan 매퍼 빈들이 SqlSessionTemplate 을 요구한다(웹 슬라이스엔 DataSource 없음). * 매퍼는 서비스 모킹으로 실행되지 않으므로, DB 연결 없는 경량 팩토리로 빈 생성만 통과시킨다. */ @TestConfiguration static class MyBatisStubConfig { @org.springframework.context.annotation.Bean org.apache.ibatis.session.SqlSessionFactory sqlSessionFactory() { org.apache.ibatis.session.Configuration cfg = new org.apache.ibatis.session.Configuration(); cfg.setEnvironment(new org.apache.ibatis.mapping.Environment( "test", new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(), new org.apache.ibatis.datasource.unpooled.UnpooledDataSource())); return new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(cfg); } @org.springframework.context.annotation.Bean org.mybatis.spring.SqlSessionTemplate sqlSessionTemplate(org.apache.ibatis.session.SqlSessionFactory f) { return new org.mybatis.spring.SqlSessionTemplate(f); } } private SessionUser loggedIn() { return SessionUser.builder().id(1L).loginId("u1").name("N").role("USER").provider("LOCAL").build(); } // ===== 보호 경로: 토큰 없으면 401 (500 아님) — 인터셉터 등록 회귀 가드 ===== @Test @DisplayName("verify-password: 토큰 없으면 401") void verifyPassword_noToken_401() throws Exception { mvc.perform(post("/api/auth/verify-password") .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"pw\"}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("profile: 토큰 없으면 401") void profile_noToken_401() throws Exception { mvc.perform(put("/api/auth/profile") .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"새이름\"}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("me: 토큰 없으면 401") void me_noToken_401() throws Exception { mvc.perform(get("/api/auth/me")) .andExpect(status().isUnauthorized()); } // ===== 보호 경로: 유효 토큰이면 컨트롤러까지 도달(CURRENT_MEMBER 주입) ===== @Test @DisplayName("verify-password: 유효 토큰이면 컨트롤러 도달 → 204") void verifyPassword_validToken_204() throws Exception { when(authService.getSession("valid")).thenReturn(loggedIn()); mvc.perform(post("/api/auth/verify-password") .header("Authorization", "Bearer valid") .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"pw\"}")) .andExpect(status().isNoContent()); verify(authService).verifyPassword(1L, "pw"); } @Test @DisplayName("profile: 유효 토큰이면 변경 → 200") void profile_validToken_200() throws Exception { when(authService.getSession("valid")).thenReturn(loggedIn()); when(authService.updateProfile(eq(1L), eq("valid"), any())) .thenReturn(MemberResponse.builder().id(1L).loginId("u1").name("새이름").email("a@b.com").build()); mvc.perform(put("/api/auth/profile") .header("Authorization", "Bearer valid") .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"새이름\",\"email\":\"a@b.com\"}")) .andExpect(status().isOk()); } // ===== 공개 경로: 토큰 없이도 접근 가능(인터셉터 미적용) ===== @Test @DisplayName("signup-enabled: 공개 — 토큰 없이 200") void signupEnabled_public_200() throws Exception { when(appSettingService.isSignupEnabled()).thenReturn(true); mvc.perform(get("/api/auth/signup-enabled")) .andExpect(status().isOk()); } }