From 4a8590218320c1dc1f2c22a666bb88aab331c828 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sat, 6 Jun 2026 14:49:48 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=EC=9D=B8=EC=A6=9D=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=9B=B9=20=EA=B3=84=EC=B8=B5=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=ED=85=8C=EC=8A=A4=ED=8A=B8=20+=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20DB=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthControllerWebMvcTest(@WebMvcTest): 실제 인터셉터·경로매핑 로드해 검증 · 보호 경로(verify-password/profile/me) 토큰 없으면 401(500 아님) · 유효 토큰이면 컨트롤러 도달(CURRENT_MEMBER 주입), 공개 경로(signup-enabled) 200 · 단위테스트가 못 잡은 인터셉터 와이어링 누락(이번 500 버그)을 잡는 계층 · @MapperScan 매퍼는 DB 없는 경량 SqlSessionFactory로 빈 생성만 통과 - deploy.yaml: clean build 가 @SpringBootTest(SbBtApplicationTests) 포함 → CI와 동일한 MariaDB/Redis 서비스+env 추가 (없으면 배포 게이트가 DB 못 붙어 실패 → 직전 게이트 변경이 배포를 깨뜨렸던 문제 수정) Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/deploy.yaml | 28 ++++ .../controller/AuthControllerWebMvcTest.java | 135 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index c740de4..ef4eb04 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -17,6 +17,34 @@ on: jobs: deploy: runs-on: ubuntu-latest + + # clean build 가 테스트(@SpringBootTest 컨텍스트 로드 포함)를 돌리므로 CI와 동일한 DB/Redis 필요 + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: ci_root_pw + MARIADB_DATABASE: sb_db + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=15 + redis: + image: redis:7 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + DB_URL: jdbc:mariadb://mariadb:3306/sb_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul + DB_USERNAME: root + DB_PASSWORD: ci_root_pw + REDIS_HOST: redis + REDIS_PORT: '6379' + steps: - name: Checkout uses: actions/checkout@v4 diff --git a/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java b/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java new file mode 100644 index 0000000..44993e9 --- /dev/null +++ b/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java @@ -0,0 +1,135 @@ +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; + + /** + * @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()); + } +}