diff --git a/src/main/java/com/devpick/domain/user/repository/TagRepository.java b/src/main/java/com/devpick/domain/user/repository/TagRepository.java index add59567..323dde13 100644 --- a/src/main/java/com/devpick/domain/user/repository/TagRepository.java +++ b/src/main/java/com/devpick/domain/user/repository/TagRepository.java @@ -13,4 +13,6 @@ public interface TagRepository extends JpaRepository { List findByNameIgnoreCaseIn(List names); java.util.Optional findByName(String name); + + java.util.Optional findByNameIgnoreCase(String name); } diff --git a/src/main/java/com/devpick/domain/user/service/UserService.java b/src/main/java/com/devpick/domain/user/service/UserService.java index 03eaf7cc..adf2f643 100644 --- a/src/main/java/com/devpick/domain/user/service/UserService.java +++ b/src/main/java/com/devpick/domain/user/service/UserService.java @@ -114,10 +114,10 @@ public String resolvePreferredAiLevel(UUID userId, String requestedLevel) { .orElse("JUNIOR"); } - /** 태그명으로 Tag 조회, 없으면 신규 생성 후 반환. */ + /** 태그명으로 대소문자 무관하게 Tag 조회, 없으면 신규 생성 후 반환. */ private List findOrCreateTags(List names) { return names.stream() - .map(name -> tagRepository.findByName(name) + .map(name -> tagRepository.findByNameIgnoreCase(name) .orElseGet(() -> tagRepository.save(Tag.builder().name(name).build()))) .toList(); } diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index d5405751..23195e29 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,3 +1,52 @@ +-- DP-462: 중복 태그 제거 — css→CSS, Github→GitHub 병합 (case 버그로 생성된 중복 row 정리) +DO $$ +DECLARE + v_dup_id UUID; + v_keep_id UUID; +BEGIN + -- css → CSS 병합 + SELECT id INTO v_dup_id FROM tags WHERE name = 'css'; + SELECT id INTO v_keep_id FROM tags WHERE name = 'CSS'; + IF v_dup_id IS NOT NULL AND v_keep_id IS NOT NULL THEN + UPDATE content_tags SET tag_id = v_keep_id + WHERE tag_id = v_dup_id + AND NOT EXISTS (SELECT 1 FROM content_tags x WHERE x.content_id = content_tags.content_id AND x.tag_id = v_keep_id); + DELETE FROM content_tags WHERE tag_id = v_dup_id; + UPDATE user_tags SET tag_id = v_keep_id + WHERE tag_id = v_dup_id + AND NOT EXISTS (SELECT 1 FROM user_tags x WHERE x.user_id = user_tags.user_id AND x.tag_id = v_keep_id); + DELETE FROM user_tags WHERE tag_id = v_dup_id; + DELETE FROM tags WHERE id = v_dup_id; + END IF; + + -- Github → GitHub 병합 + SELECT id INTO v_dup_id FROM tags WHERE name = 'Github'; + SELECT id INTO v_keep_id FROM tags WHERE name = 'GitHub'; + IF v_dup_id IS NOT NULL AND v_keep_id IS NOT NULL THEN + UPDATE content_tags SET tag_id = v_keep_id + WHERE tag_id = v_dup_id + AND NOT EXISTS (SELECT 1 FROM content_tags x WHERE x.content_id = content_tags.content_id AND x.tag_id = v_keep_id); + DELETE FROM content_tags WHERE tag_id = v_dup_id; + UPDATE user_tags SET tag_id = v_keep_id + WHERE tag_id = v_dup_id + AND NOT EXISTS (SELECT 1 FROM user_tags x WHERE x.user_id = user_tags.user_id AND x.tag_id = v_keep_id); + DELETE FROM user_tags WHERE tag_id = v_dup_id; + DELETE FROM tags WHERE id = v_dup_id; + END IF; +END $$; + +-- DP-462: 한국어 태그 → 영어 rename (AI 프롬프트 기준값 일치) +UPDATE tags SET name = 'Algorithm' WHERE name = '알고리즘'; +UPDATE tags SET name = 'Data Structure' WHERE name = '자료구조'; +UPDATE tags SET name = 'OS' WHERE name = '운영체제'; +UPDATE tags SET name = 'Network' WHERE name = '네트워크'; +UPDATE tags SET name = 'Design Pattern' WHERE name = '디자인패턴'; +UPDATE tags SET name = 'Compiler' WHERE name = '컴파일러'; +UPDATE tags SET name = 'Concurrency' WHERE name = '동시성'; +UPDATE tags SET name = 'Parallel Programming' WHERE name = '병렬프로그래밍'; +UPDATE tags SET name = 'Memory Management' WHERE name = '메모리관리'; +UPDATE tags SET name = 'Garbage Collection' WHERE name = '가비지컬렉션'; + -- DP-150: 태그 초기 데이터 삽입 -- 서버 시작 시 tags 테이블에 기본 태그들을 삽입한다. -- 이미 존재하는 태그는 건너뜀 (ON CONFLICT DO NOTHING) @@ -64,18 +113,18 @@ INSERT INTO tags (id, name, created_at) VALUES -- 아키텍처 / 설계 (gen_random_uuid(), 'MSA', NOW()), (gen_random_uuid(), '시스템설계', NOW()), -(gen_random_uuid(), '디자인패턴', NOW()), +(gen_random_uuid(), 'Design Pattern', NOW()), (gen_random_uuid(), '클린코드', NOW()), (gen_random_uuid(), '객체지향', NOW()), (gen_random_uuid(), '함수형프로그래밍', NOW()), (gen_random_uuid(), 'DDD', NOW()), -- CS 기초 -(gen_random_uuid(), '네트워크', NOW()), -(gen_random_uuid(), '운영체제', NOW()), -(gen_random_uuid(), '컴파일러', NOW()), +(gen_random_uuid(), 'Network', NOW()), +(gen_random_uuid(), 'OS', NOW()), +(gen_random_uuid(), 'Compiler', NOW()), (gen_random_uuid(), '데이터베이스이론', NOW()), (gen_random_uuid(), '컴퓨터구조', NOW()), -(gen_random_uuid(), '병렬프로그래밍', NOW()), +(gen_random_uuid(), 'Parallel Programming', NOW()), -- AI 모델 / 플랫폼 (gen_random_uuid(), 'Claude', NOW()), (gen_random_uuid(), 'ChatGPT', NOW()), @@ -154,13 +203,13 @@ INSERT INTO tags (id, name, created_at) VALUES (gen_random_uuid(), 'Hugging Face', NOW()), (gen_random_uuid(), 'Ollama', NOW()), -- CS 심화 -(gen_random_uuid(), '동시성', NOW()), -(gen_random_uuid(), '메모리관리', NOW()), -(gen_random_uuid(), '가비지컬렉션', NOW()), +(gen_random_uuid(), 'Concurrency', NOW()), +(gen_random_uuid(), 'Memory Management', NOW()), +(gen_random_uuid(), 'Garbage Collection', NOW()), -- 기타 (gen_random_uuid(), 'Git', NOW()), -(gen_random_uuid(), '알고리즘', NOW()), -(gen_random_uuid(), '자료구조', NOW()), +(gen_random_uuid(), 'Algorithm', NOW()), +(gen_random_uuid(), 'Data Structure', NOW()), (gen_random_uuid(), '보안', NOW()), (gen_random_uuid(), '테스트', NOW()), (gen_random_uuid(), 'AI/ML', NOW()), diff --git a/src/test/java/com/devpick/domain/user/service/UserServiceTest.java b/src/test/java/com/devpick/domain/user/service/UserServiceTest.java index befef906..effe23a3 100644 --- a/src/test/java/com/devpick/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/devpick/domain/user/service/UserServiceTest.java @@ -246,7 +246,7 @@ void updateProfile_duplicateNickname_throwsException() { void updateProfile_tags_returnsUpdatedTags() { given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); Tag reactTag = Tag.builder().name("React").build(); - given(tagRepository.findByName("React")).willReturn(Optional.of(reactTag)); + given(tagRepository.findByNameIgnoreCase("React")).willReturn(Optional.of(reactTag)); UserProfileUpdateRequest request = new UserProfileUpdateRequest(null, null, null, null, List.of("React")); UserProfileUpdateResponse response = userService.updateProfile(userId, request); @@ -260,8 +260,8 @@ void updateProfile_tags_replacedWithoutDuplicate() { given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); Tag reactTag = Tag.builder().name("React").build(); Tag tsTag = Tag.builder().name("TypeScript").build(); - given(tagRepository.findByName("React")).willReturn(Optional.of(reactTag)); - given(tagRepository.findByName("TypeScript")).willReturn(Optional.of(tsTag)); + given(tagRepository.findByNameIgnoreCase("React")).willReturn(Optional.of(reactTag)); + given(tagRepository.findByNameIgnoreCase("TypeScript")).willReturn(Optional.of(tsTag)); UserProfileUpdateRequest request = new UserProfileUpdateRequest(null, null, null, null, List.of("React", "TypeScript")); UserProfileUpdateResponse response = userService.updateProfile(userId, request); @@ -270,6 +270,35 @@ void updateProfile_tags_replacedWithoutDuplicate() { assertThat(response.tags()).doesNotHaveDuplicates(); } + @Test + @DisplayName("updateProfile — 대소문자 다른 태그 입력 시 기존 tag row 재사용, 신규 row 미생성") + void updateProfile_tags_caseInsensitive_reusesExistingTag() { + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + Tag cssTag = Tag.builder().name("CSS").build(); + given(tagRepository.findByNameIgnoreCase("css")).willReturn(Optional.of(cssTag)); + UserProfileUpdateRequest request = new UserProfileUpdateRequest(null, null, null, null, List.of("css")); + + UserProfileUpdateResponse response = userService.updateProfile(userId, request); + + assertThat(response.tags()).containsExactly("CSS"); + verify(tagRepository, never()).save(any()); + } + + @Test + @DisplayName("updateProfile — 존재하지 않는 태그는 신규 생성된다") + void updateProfile_tags_unknown_createsNewRow() { + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + Tag newTag = Tag.builder().name("Zig").build(); + given(tagRepository.findByNameIgnoreCase("Zig")).willReturn(Optional.empty()); + given(tagRepository.save(any(Tag.class))).willReturn(newTag); + UserProfileUpdateRequest request = new UserProfileUpdateRequest(null, null, null, null, List.of("Zig")); + + UserProfileUpdateResponse response = userService.updateProfile(userId, request); + + assertThat(response.tags()).containsExactly("Zig"); + verify(tagRepository).save(any(Tag.class)); + } + @Test @DisplayName("deleteAccount — 소프트 삭제 및 리프레시 토큰 무효화") void deleteAccount_success() {