test 추가

This commit is contained in:
2026-04-24 14:08:54 +09:00
parent b1bcabaf6c
commit 9095b1e7a7
18 changed files with 617 additions and 7 deletions

View File

@@ -82,6 +82,11 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>

View File

@@ -24,6 +24,7 @@ mkdir -p "$LOG_DIR"
# 시작 # 시작
echo "[$APP_NAME] 시작 중..." echo "[$APP_NAME] 시작 중..."
nohup "$JAVA_HOME/bin/java" \ nohup "$JAVA_HOME/bin/java" \
-DAPP_HASH_SALT="${APP_HASH_SALT}" \
-jar "$JAR" \ -jar "$JAR" \
--spring.profiles.active=prod \ --spring.profiles.active=prod \
> /dev/null 2>&1 & > /dev/null 2>&1 &

View File

@@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication @SpringBootApplication
@MapperScan("com.owrawww.domain.mapper") @MapperScan({"com.owrawww.domain.mapper", "com.owrawww.migration"})
public class OwrawwwApplication { public class OwrawwwApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -45,5 +45,7 @@ public class Inquiry {
private Integer subGubun; private Integer subGubun;
private Integer depth; private Integer depth;
private String solutionGubun; private String solutionGubun;
private String telHash; // SHA-256(솔트+tel) - 검색/중복체크용
private String emailHash; // SHA-256(솔트+email) - 검색/중복체크용
} }

View File

@@ -0,0 +1,21 @@
package com.owrawww.migration;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface DataMigrationMapper {
/** 미암호화 레코드 조회 (phone 길이 30자 이하 = 평문) */
List<Map<String, Object>> selectPlainRecords();
/** phone, tel_hash, email, email_hash 일괄 업데이트 */
int updateEncrypted(@Param("id") Long id,
@Param("phone") String phone,
@Param("telHash") String telHash,
@Param("email") String email,
@Param("emailHash") String emailHash);
}

View File

@@ -0,0 +1,81 @@
package com.owrawww.migration;
import com.owrawww.util.AesUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
/**
* 기존 평문 phone/email 데이터를 일괄 암호화하는 마이그레이션 Runner.
*
* 실행 방법 (운영 서버):
* java -jar owrawww.jar --spring.profiles.active=prod,migration
*
* 주의사항:
* - 한 번만 실행해야 합니다. 재실행 시 이미 암호화된 데이터는 자동으로 건너뜁니다.
* - 실행 전 DB 백업을 반드시 수행하세요.
* - 마이그레이션 완료 후 이 profile 없이 앱을 재시작하세요.
*/
@Slf4j
@Profile("migration")
@Component
@RequiredArgsConstructor
public class DataMigrationRunner implements ApplicationRunner {
private final DataMigrationMapper migrationMapper;
private final AesUtil aesUtil;
@Override
@Transactional
public void run(ApplicationArguments args) {
log.info("=== [마이그레이션 시작] phone/email 암호화 ===");
List<Map<String, Object>> records = migrationMapper.selectPlainRecords();
log.info("암호화 대상 레코드 수: {}건", records.size());
if (records.isEmpty()) {
log.info("암호화할 평문 데이터가 없습니다. 이미 완료됐거나 데이터가 없습니다.");
return;
}
int success = 0;
int skip = 0;
for (Map<String, Object> row : records) {
Long id = ((Number) row.get("id")).longValue();
String phone = (String) row.get("phone");
String email = (String) row.get("email");
// null/빈값 방어
if (phone == null || phone.isBlank() || email == null || email.isBlank()) {
log.warn(" ID={} : phone 또는 email 이 비어있어 건너뜁니다.", id);
skip++;
continue;
}
try {
String encPhone = aesUtil.encrypt(phone);
String hashPhone = aesUtil.hash(phone);
String encEmail = aesUtil.encrypt(email);
String hashEmail = aesUtil.hash(email);
migrationMapper.updateEncrypted(id, encPhone, hashPhone, encEmail, hashEmail);
log.info(" ID={} : 암호화 완료", id);
success++;
} catch (Exception e) {
log.error(" ID={} : 암호화 실패 - {}", id, e.getMessage());
throw new RuntimeException("ID=" + id + " 처리 중 오류 발생. 트랜잭션 롤백.", e);
}
}
log.info("=== [마이그레이션 완료] 성공: {}건, 건너뜀: {}건 ===", success, skip);
log.info("마이그레이션이 끝났습니다. 'migration' 프로파일 없이 앱을 재시작하세요.");
}
}

View File

@@ -20,7 +20,9 @@ public class InquiryService {
savedInquiry.setComment(inquiry.getContent()); savedInquiry.setComment(inquiry.getContent());
savedInquiry.setName(inquiry.getName()); savedInquiry.setName(inquiry.getName());
savedInquiry.setTel(aesUtil.encrypt(inquiry.getTel())); savedInquiry.setTel(aesUtil.encrypt(inquiry.getTel()));
savedInquiry.setTelHash(aesUtil.hash(inquiry.getTel()));
savedInquiry.setEmail(aesUtil.encrypt(inquiry.getEmail())); savedInquiry.setEmail(aesUtil.encrypt(inquiry.getEmail()));
savedInquiry.setEmailHash(aesUtil.hash(inquiry.getEmail()));
savedInquiry.setTopCode(2); savedInquiry.setTopCode(2);
savedInquiry.setLeftCode(1); savedInquiry.setLeftCode(1);
savedInquiry.setSubGubun(1); savedInquiry.setSubGubun(1);

View File

@@ -7,8 +7,12 @@ import javax.crypto.Cipher;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; import java.util.Base64;
import java.util.HexFormat;
/** /**
* AES-256-GCM 암호화/복호화 유틸리티 * AES-256-GCM 암호화/복호화 유틸리티
@@ -22,13 +26,17 @@ public class AesUtil {
private static final int TAG_BIT_LEN = 128; // GCM 인증 태그 크기 private static final int TAG_BIT_LEN = 128; // GCM 인증 태그 크기
private final SecretKey secretKey; private final SecretKey secretKey;
private final String hashSalt;
public AesUtil(@Value("${app.encryption.key}") String base64Key) { public AesUtil(
@Value("${app.encryption.key}") String base64Key,
@Value("${app.hash.salt}") String hashSalt) {
byte[] keyBytes = Base64.getDecoder().decode(base64Key); byte[] keyBytes = Base64.getDecoder().decode(base64Key);
if (keyBytes.length != 32) { if (keyBytes.length != 32) {
throw new IllegalArgumentException("암호화 키는 Base64 인코딩된 32바이트(256bit)여야 합니다."); throw new IllegalArgumentException("암호화 키는 Base64 인코딩된 32바이트(256bit)여야 합니다.");
} }
this.secretKey = new SecretKeySpec(keyBytes, "AES"); this.secretKey = new SecretKeySpec(keyBytes, "AES");
this.hashSalt = hashSalt;
} }
/** /**
@@ -75,4 +83,22 @@ public class AesUtil {
throw new RuntimeException("복호화 실패", e); throw new RuntimeException("복호화 실패", e);
} }
} }
/**
* 평문 → SHA-256(솔트 + 평문) → HEX 문자열 (검색/중복체크용)
* - 단방향: 복호화 불가
* - 솔트로 레인보우 테이블 공격 방어
*/
public String hash(String plainText) {
if (plainText == null || plainText.isEmpty()) return plainText;
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(
(hashSalt + plainText).getBytes(StandardCharsets.UTF_8)
);
return HexFormat.of().formatHex(hashBytes);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 알고리즘을 사용할 수 없습니다.", e);
}
}
} }

View File

@@ -33,4 +33,5 @@ app:
path: /home/www/owrainfo/uploads path: /home/www/owrainfo/uploads
encryption: encryption:
key: PIYAsB81yr7uETVoM/E7Sz9Sp5kNB46uHyjnfQE3XOs= # 운영 전용 키로 교체 필요 (base64 인코딩된 32바이트) key: PIYAsB81yr7uETVoM/E7Sz9Sp5kNB46uHyjnfQE3XOs= # 운영 전용 키로 교체 필요 (base64 인코딩된 32바이트)
hash:
salt: ${APP_HASH_SALT:9C4622A0E11849F94C490FCE1E6114F05BFF98FCEB5B85067F09E35ADD0D3FAB} # 반드시 환경변수로 주입 (서버에서 설정)

View File

@@ -24,4 +24,7 @@ logging:
app: app:
upload: upload:
path: /home/www/owrainfo/uploads path: /home/www/owrainfo/uploads
encryption:
key: PIYAsB81yr7uETVoM/E7Sz9Sp5kNB46uHyjnfQE3XOs= # 운영 전용 키로 교체 필요 (base64 인코딩된 32바이트)
hash:
salt: ${APP_HASH_SALT:9C4622A0E11849F94C490FCE1E6114F05BFF98FCEB5B85067F09E35ADD0D3FAB}

View File

@@ -32,3 +32,5 @@ app:
path: D:/uploads/owrawww/careers # 기본값 (프로파일별로 override) path: D:/uploads/owrawww/careers # 기본값 (프로파일별로 override)
encryption: encryption:
key: PIYAsB81yr7uETVoM/E7Sz9Sp5kNB46uHyjnfQE3XOs= # AES-256 키 (운영에서는 반드시 별도 키로 교체) key: PIYAsB81yr7uETVoM/E7Sz9Sp5kNB46uHyjnfQE3XOs= # AES-256 키 (운영에서는 반드시 별도 키로 교체)
hash:
salt: ${APP_HASH_SALT:3DBD3CD58A81C407AD0D3E10515DAD7544E70911A2E43EF3A07042B102D41716} # 환경변수 우선, 없으면 기본값

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.owrawww.migration.DataMigrationMapper">
<!--
평문 레코드 판별 기준:
- 암호화된 값: AES-GCM(12바이트 IV + 데이터 + 16바이트 태그) → Base64 → 최소 약 50자 이상
- 평문 전화번호: 최대 20자 (예: 010-1234-5678)
- LENGTH(phone) <= 30 이면 아직 평문으로 판단
-->
<select id="selectPlainRecords" resultType="map">
SELECT id, phone, email
FROM application_table
WHERE LENGTH(phone) &lt;= 30
ORDER BY id
</select>
<update id="updateEncrypted">
UPDATE application_table
SET phone = #{phone},
tel_hash = #{telHash},
email = #{email},
email_hash = #{emailHash}
WHERE id = #{id}
</update>
</mapper>

View File

@@ -5,8 +5,8 @@
<mapper namespace="com.owrawww.domain.mapper.InquiryMapper"> <mapper namespace="com.owrawww.domain.mapper.InquiryMapper">
<insert id="insert" parameterType="com.owrawww.domain.Inquiry" useGeneratedKeys="true" keyProperty="id"> <insert id="insert" parameterType="com.owrawww.domain.Inquiry" useGeneratedKeys="true" keyProperty="id">
INSERT INTO application_table (code, title, name, type, phone, email, comment, depth, in_date, top_code, left_code, sub_gubun, solution_gubun) INSERT INTO application_table (code, title, name, type, phone, tel_hash, email, email_hash, comment, depth, in_date, top_code, left_code, sub_gubun, solution_gubun)
VALUES (#{code}, #{title}, #{name}, #{type}, #{tel}, #{email}, #{comment}, #{depth}, now(), #{topCode}, #{leftCode}, #{subGubun}, #{solutionGubun}) VALUES (#{code}, #{title}, #{name}, #{type}, #{tel}, #{telHash}, #{email}, #{emailHash}, #{comment}, #{depth}, now(), #{topCode}, #{leftCode}, #{subGubun}, #{solutionGubun})
</insert> </insert>
</mapper> </mapper>

View File

@@ -37,3 +37,12 @@ CREATE TABLE IF NOT EXISTS inquiry (
-- BCrypt 해시값 사전 생성 필요 시 아래 INSERT 사용 -- BCrypt 해시값 사전 생성 필요 시 아래 INSERT 사용
-- INSERT INTO users (username, password, role, email, enabled) -- INSERT INTO users (username, password, role, email, enabled)
-- VALUES ('admin', '$2a$10$...', 'ROLE_ADMIN', 'admin@owrawww.com', 1); -- VALUES ('admin', '$2a$10$...', 'ROLE_ADMIN', 'admin@owrawww.com', 1);
-- =====================================================================
-- [마이그레이션] tel_hash / email_hash 컬럼 추가 (솔트 해시 검색용)
-- 운영 DB에 아래 ALTER TABLE 를 직접 실행해야 합니다.
-- =====================================================================
-- ALTER TABLE application_table ADD COLUMN tel_hash VARCHAR(64) AFTER phone;
-- ALTER TABLE application_table ADD COLUMN email_hash VARCHAR(64) AFTER email;
-- CREATE INDEX idx_app_tel_hash ON application_table(tel_hash);
-- CREATE INDEX idx_app_email_hash ON application_table(email_hash);

View File

@@ -0,0 +1,100 @@
package com.owrawww.controller;
import com.owrawww.domain.Bbs;
import com.owrawww.service.BbsService;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.transaction.TransactionFactory;
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Import(HomeControllerTest.MybatisTestConfig.class)
@WebMvcTest(value = HomeController.class, excludeAutoConfiguration = MybatisAutoConfiguration.class)
class HomeControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private BbsService bbsService;
@Test
@DisplayName("GET / - 메인 페이지 정상 응답")
void index_returnsIndexView() throws Exception {
Bbs bbs = new Bbs();
bbs.setId(1L);
bbs.setTitle("공지사항");
given(bbsService.getPreview(anyInt(), anyInt(), anyInt(), anyInt()))
.willReturn(List.of(bbs));
mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(view().name("index"))
.andExpect(model().attributeExists("progressList"))
.andExpect(model().attributeExists("pressList"));
}
@Test
@DisplayName("GET /sitemap - 사이트맵 뷰 반환")
void sitemap_returnsSitemapView() throws Exception {
mockMvc.perform(get("/sitemap"))
.andExpect(status().isOk())
.andExpect(view().name("sitemap"));
}
@Test
@DisplayName("GET /company/intro - 회사소개 뷰 반환")
void companyIntro_returnsView() throws Exception {
mockMvc.perform(get("/company/intro"))
.andExpect(status().isOk())
.andExpect(view().name("sub01_01"));
}
@Test
@DisplayName("GET /business/intro - 사업소개 뷰 반환")
void businessIntro_returnsView() throws Exception {
mockMvc.perform(get("/business/intro"))
.andExpect(status().isOk())
.andExpect(view().name("sub02_01"));
}
@Test
@DisplayName("GET /company/map - 오시는길 뷰 반환")
void companyMap_returnsView() throws Exception {
mockMvc.perform(get("/company/map"))
.andExpect(status().isOk())
.andExpect(view().name("sub01_05"));
}
@TestConfiguration
static class MybatisTestConfig {
@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
TransactionFactory tf = new SpringManagedTransactionFactory();
javax.sql.DataSource ds = Mockito.mock(javax.sql.DataSource.class);
Environment env = new Environment("test", tf, ds);
org.apache.ibatis.session.Configuration conf = new org.apache.ibatis.session.Configuration(env);
SqlSessionFactory factory = Mockito.mock(SqlSessionFactory.class);
Mockito.lenient().when(factory.getConfiguration()).thenReturn(conf);
return factory;
}
}
}

View File

@@ -0,0 +1,138 @@
package com.owrawww.controller;
import com.owrawww.service.InquiryService;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.transaction.TransactionFactory;
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Import(InquiryControllerTest.MybatisTestConfig.class)
@WebMvcTest(value = InquiryController.class, excludeAutoConfiguration = MybatisAutoConfiguration.class)
class InquiryControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private InquiryService inquiryService;
@Test
@DisplayName("GET /sub04_02 - 문의 폼 페이지 정상 응답")
void inquiryForm_returnsView() throws Exception {
mockMvc.perform(get("/sub04_02"))
.andExpect(status().isOk())
.andExpect(view().name("sub04_02"));
}
@Test
@DisplayName("POST /sub04_02 - 유효한 입력 시 성공 응답")
void inquirySubmit_validInput_returnsSuccess() throws Exception {
given(inquiryService.submit(any())).willReturn(true);
mockMvc.perform(post("/sub04_02")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "홍길동")
.param("email", "hong@example.com")
.param("tel", "010-1234-5678")
.param("dept", "개발팀")
.param("title", "도입 문의")
.param("content", "서비스 도입 관련 문의드립니다.")
.param("solutionGubun", "PG"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
@DisplayName("POST /sub04_02 - 이름 누락 시 400 Bad Request")
void inquirySubmit_missingName_returnsBadRequest() throws Exception {
mockMvc.perform(post("/sub04_02")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("email", "hong@example.com")
.param("tel", "010-1234-5678")
.param("dept", "개발팀")
.param("title", "도입 문의")
.param("content", "서비스 도입 관련 문의드립니다."))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
@Test
@DisplayName("POST /sub04_02 - 이메일 형식 오류 시 400 Bad Request")
void inquirySubmit_invalidEmail_returnsBadRequest() throws Exception {
mockMvc.perform(post("/sub04_02")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "홍길동")
.param("email", "not-an-email")
.param("tel", "010-1234-5678")
.param("dept", "개발팀")
.param("title", "도입 문의")
.param("content", "내용"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
@Test
@DisplayName("POST /sub04_02 - 연락처 형식 오류 시 400 Bad Request")
void inquirySubmit_invalidTel_returnsBadRequest() throws Exception {
mockMvc.perform(post("/sub04_02")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "홍길동")
.param("email", "hong@example.com")
.param("tel", "invalid!")
.param("dept", "개발팀")
.param("title", "도입 문의")
.param("content", "내용"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
@Test
@DisplayName("POST /sub04_02 - 서비스 저장 실패 시 success false 반환")
void inquirySubmit_serviceFailure_returnsFailure() throws Exception {
given(inquiryService.submit(any())).willReturn(false);
mockMvc.perform(post("/sub04_02")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "홍길동")
.param("email", "hong@example.com")
.param("tel", "010-1234-5678")
.param("dept", "개발팀")
.param("title", "도입 문의")
.param("content", "서비스 도입 관련 문의드립니다.")
.param("solutionGubun", "PG"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").exists());
}
@TestConfiguration
static class MybatisTestConfig {
@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
TransactionFactory tf = new SpringManagedTransactionFactory();
javax.sql.DataSource ds = Mockito.mock(javax.sql.DataSource.class);
Environment env = new Environment("test", tf, ds);
org.apache.ibatis.session.Configuration conf = new org.apache.ibatis.session.Configuration(env);
SqlSessionFactory factory = Mockito.mock(SqlSessionFactory.class);
Mockito.lenient().when(factory.getConfiguration()).thenReturn(conf);
return factory;
}
}
}

View File

@@ -0,0 +1,103 @@
package com.owrawww.service;
import com.owrawww.domain.Bbs;
import com.owrawww.domain.mapper.BbsMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class BbsServiceTest {
@Mock
private BbsMapper bbsMapper;
@InjectMocks
private BbsService bbsService;
private Bbs sampleBbs;
@BeforeEach
void setUp() {
sampleBbs = new Bbs();
sampleBbs.setId(1L);
sampleBbs.setTitle("테스트 제목");
sampleBbs.setName("작성자");
sampleBbs.setBody("내용입니다.");
sampleBbs.setViewCnt(0);
}
@Test
@DisplayName("getList - 정상적으로 목록과 페이징 정보를 반환한다")
void getList_returnsListAndPagingInfo() {
given(bbsMapper.selectList(any())).willReturn(List.of(sampleBbs, sampleBbs));
given(bbsMapper.selectCount(any())).willReturn(2);
Map<String, Object> result = bbsService.getList(1, "all", "", 1, 1, 1);
assertThat(result.get("list")).isEqualTo(List.of(sampleBbs, sampleBbs));
assertThat(result.get("totalCount")).isEqualTo(2);
assertThat(result.get("currentPage")).isEqualTo(1);
assertThat(result.get("totalPages")).isEqualTo(1);
}
@Test
@DisplayName("getList - 페이지 번호가 0 이하이면 1로 보정된다")
void getList_pageUnderOne_normalizedToOne() {
given(bbsMapper.selectList(any())).willReturn(List.of());
given(bbsMapper.selectCount(any())).willReturn(0);
Map<String, Object> result = bbsService.getList(0, "all", "", 1, 1, 1);
assertThat(result.get("currentPage")).isEqualTo(1);
}
@Test
@DisplayName("getList - totalCount가 0이면 totalPages는 1이다")
void getList_emptyResult_totalPagesIsOne() {
given(bbsMapper.selectList(any())).willReturn(List.of());
given(bbsMapper.selectCount(any())).willReturn(0);
Map<String, Object> result = bbsService.getList(1, "all", "", 1, 1, 1);
assertThat(result.get("totalPages")).isEqualTo(1);
}
@Test
@DisplayName("getDetail - 조회수 증가 후 게시글과 이전/다음 정보를 반환한다")
void getDetail_incrementsViewCntAndReturnsDetail() {
given(bbsMapper.selectById(any())).willReturn(sampleBbs);
given(bbsMapper.selectPrev(any())).willReturn(null);
given(bbsMapper.selectNext(any())).willReturn(null);
Map<String, Object> result = bbsService.getDetail(1L, 1, 1, 1, 1, "all", "");
verify(bbsMapper).incrementViewCnt(1L);
assertThat(result.get("bbs")).isEqualTo(sampleBbs);
assertThat(result.get("prev")).isNull();
assertThat(result.get("next")).isNull();
}
@Test
@DisplayName("getPreview - limit 개수만큼 미리보기 목록을 반환한다")
void getPreview_returnsLimitedList() {
given(bbsMapper.selectPreview(any())).willReturn(List.of(sampleBbs));
List<Bbs> result = bbsService.getPreview(1, 2, 1, 5);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("테스트 제목");
}
}

View File

@@ -0,0 +1,87 @@
package com.owrawww.service;
import com.owrawww.domain.Inquiry;
import com.owrawww.domain.mapper.InquiryMapper;
import com.owrawww.util.AesUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class InquiryServiceTest {
@Mock
private InquiryMapper inquiryMapper;
@Mock
private AesUtil aesUtil;
@InjectMocks
private InquiryService inquiryService;
private Inquiry inquiry;
@BeforeEach
void setUp() {
inquiry = new Inquiry();
inquiry.setName("홍길동");
inquiry.setEmail("hong@example.com");
inquiry.setTel("010-1234-5678");
inquiry.setTitle("도입 문의");
inquiry.setContent("서비스 도입 관련 문의드립니다.");
inquiry.setSolutionGubun("PG");
}
@Test
@DisplayName("submit - 정상 입력 시 저장에 성공하고 true를 반환한다")
void submit_success() {
given(aesUtil.encrypt(inquiry.getTel())).willReturn("ENCRYPTED_TEL");
given(aesUtil.encrypt(inquiry.getEmail())).willReturn("ENCRYPTED_EMAIL");
given(aesUtil.hash(inquiry.getTel())).willReturn("HASH_TEL");
given(aesUtil.hash(inquiry.getEmail())).willReturn("HASH_EMAIL");
given(inquiryMapper.insert(any(Inquiry.class))).willReturn(1);
boolean result = inquiryService.submit(inquiry);
assertThat(result).isTrue();
verify(inquiryMapper).insert(any(Inquiry.class));
}
@Test
@DisplayName("submit - DB 저장 실패 시 false를 반환한다")
void submit_dbFails_returnsFalse() {
given(aesUtil.encrypt(any())).willReturn("ENCRYPTED");
given(aesUtil.hash(any())).willReturn("HASHED");
given(inquiryMapper.insert(any(Inquiry.class))).willReturn(0);
boolean result = inquiryService.submit(inquiry);
assertThat(result).isFalse();
}
@Test
@DisplayName("submit - 전화번호와 이메일이 암호화 및 해시화되어 저장된다")
void submit_encryptsAndHashesSensitiveFields() {
given(aesUtil.encrypt("010-1234-5678")).willReturn("ENC_TEL");
given(aesUtil.encrypt("hong@example.com")).willReturn("ENC_EMAIL");
given(aesUtil.hash("010-1234-5678")).willReturn("HASH_TEL");
given(aesUtil.hash("hong@example.com")).willReturn("HASH_EMAIL");
given(inquiryMapper.insert(any(Inquiry.class))).willReturn(1);
inquiryService.submit(inquiry);
verify(aesUtil).encrypt("010-1234-5678");
verify(aesUtil).encrypt("hong@example.com");
verify(aesUtil).hash("010-1234-5678");
verify(aesUtil).hash("hong@example.com");
}
}