diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java index 24cadb08..41d75c01 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java @@ -1,9 +1,19 @@ package org.tuna.zoopzoop.backend.domain.datasource.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import java.util.List; + @Repository public interface TagRepository extends JpaRepository { + @Query(""" + select distinct t.tagName + from Tag t + where t.dataSource.folder.id = :folderId + """) + List findDistinctTagNamesByFolderId(@Param("folderId") Integer folderId); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java index 3e6ba728..4cb3aa96 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -8,10 +8,15 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; +import java.io.IOException; import java.time.LocalDate; import java.util.*; import java.util.stream.Collectors; @@ -22,6 +27,8 @@ public class DataSourceService { private final DataSourceRepository dataSourceRepository; private final FolderRepository folderRepository; private final PersonalArchiveRepository personalArchiveRepository; + private final TagRepository tagRepository; + private final DataProcessorService dataProcessorService; /** * 지정한 folder 위치에 자료 생성 @@ -35,23 +42,52 @@ public int createDataSource(int currentMemberId, String sourceUrl, Integer folde folder = folderRepository.findById(folderId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); - DataSource ds = buildDataSource(sourceUrl, folder); - DataSource saved = dataSourceRepository.save(ds); + // 폴더 하위 자료 태그 수집(중복 X) + List contextTags = collectDistinctTagsOfFolder(folder.getId()); + DataSource ds = buildDataSource(folder, sourceUrl, contextTags); + + // 4) 저장 + final DataSource saved = dataSourceRepository.save(ds); return saved.getId(); } - private DataSource buildDataSource(String sourceUrl, Folder folder) { + // 폴더 하위 태그 중복없이 list 반환 + private List collectDistinctTagsOfFolder(Integer folderId) { + List names = tagRepository.findDistinctTagNamesByFolderId(folderId); + + return names.stream() + .map(Tag::new) + .toList(); + } + + private DataSource buildDataSource(Folder folder, String sourceUrl, List tagList) { + final DataSourceDto dataSourceDto; + try { + dataSourceDto = dataProcessorService.process(sourceUrl, tagList); + } catch (IOException e) { + throw new RuntimeException("자료 처리 중 오류가 발생했습니다.", e); + } + DataSource ds = new DataSource(); ds.setFolder(folder); - ds.setSourceUrl(sourceUrl); - ds.setTitle("자료 제목"); - ds.setSource("www.examplesource.com"); - ds.setSummary("설명"); - ds.setImageUrl("www.example.com/img"); - ds.setDataCreatedDate(LocalDate.now()); - ds.setCategory(Category.IT); + ds.setSourceUrl(dataSourceDto.sourceUrl()); + ds.setTitle(dataSourceDto.title()); + ds.setSummary(dataSourceDto.summary()); + ds.setDataCreatedDate(dataSourceDto.dataCreatedDate()); + ds.setImageUrl(dataSourceDto.imageUrl()); + ds.setSource(dataSourceDto.source()); + ds.setCategory(dataSourceDto.category()); ds.setActive(true); + + if (dataSourceDto.tags() != null) { + for (String tagName : dataSourceDto.tags()) { + Tag tag = new Tag(tagName); + tag.setDataSource(ds); + ds.getTags().add(tag); + } + } + return ds; } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index 394a939a..7ec3393c 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -17,6 +17,7 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; @@ -272,7 +273,7 @@ void getFilesInFolderForPersonal_success() { d1.setSourceUrl("http://src/a"); d1.setImageUrl("http://img/a"); d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); - d1.setCategory(org.tuna.zoopzoop.backend.domain.datasource.entity.Category.IT); + d1.setCategory(Category.IT); DataSource d2 = new DataSource(); ReflectionTestUtils.setField(d2, "id", 11); @@ -282,7 +283,7 @@ void getFilesInFolderForPersonal_success() { d2.setSourceUrl("http://src/b"); d2.setImageUrl("http://img/b"); d2.setTags(List.of()); - d2.setCategory(org.tuna.zoopzoop.backend.domain.datasource.entity.Category.SCIENCE); + d2.setCategory(Category.SCIENCE); when(dataSourceRepository.findAllByFolder(folder)).thenReturn(List.of(d1, d2)); diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java index 2643b8c0..8f9e70a0 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java @@ -2,9 +2,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.TestExecutionEvent; import org.springframework.security.test.context.support.WithUserDetails; @@ -15,11 +19,13 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; import org.tuna.zoopzoop.backend.domain.datasource.dto.*; import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; @@ -28,6 +34,8 @@ import java.util.List; import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -37,7 +45,6 @@ @Transactional @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DatasourceControllerTest { - @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @@ -47,13 +54,47 @@ class DatasourceControllerTest { @Autowired private FolderRepository folderRepository; @Autowired private DataSourceRepository dataSourceRepository; - private final String TEST_PROVIDER_KEY = "testUser_sc1111"; // WithUserDetails username -> "KAKAO:testUser_sc1111" + private final String TEST_PROVIDER_KEY = "testUser_sc1111"; private Integer testMemberId; private Integer docsFolderId; private Integer dataSourceId1; private Integer dataSourceId2; + @TestConfiguration + static class StubConfig { + @Bean + @Primary + DataProcessorService stubDataProcessorService() throws Exception { + return new DataProcessorService(null, null) { + @Override + public DataSourceDto process(String url, List tagList) { + return new DataSourceDto( + "테스트제목", + "테스트요약", + LocalDate.of(2025, 9, 1), + url, + "https://img.example/test.png", + "example.com", + Category.IT, + List.of("ML","Infra") + ); + } + }; + } + + @Bean + @Primary + TagRepository stubTagRepository() { + TagRepository mock = Mockito.mock(TagRepository.class); + + when(mock.findDistinctTagNamesByFolderId(anyInt())) + .thenReturn(java.util.List.of("AI", "Spring")); + + return mock; + } + } + @BeforeAll void beforeAll() { try { @@ -110,7 +151,6 @@ void beforeAll() { @AfterAll void afterAll() { - // 생성한 자료/폴더/멤버 삭제 try { if (dataSourceId1 != null) dataSourceRepository.findById(dataSourceId1).ifPresent(dataSourceRepository::delete); } catch (Exception ignored) {} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java index 2660cb89..1c4c8327 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -12,11 +13,18 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import java.io.IOException; +import java.time.LocalDate; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -33,13 +41,20 @@ class DataSourceServiceTest { @Mock private DataSourceRepository dataSourceRepository; @Mock private FolderRepository folderRepository; @Mock private PersonalArchiveRepository personalArchiveRepository; + @Mock private TagRepository tagRepository; + @Mock private DataProcessorService dataProcessorService; @InjectMocks private DataSourceService dataSourceService; + private DataSourceDto dataSourceDto(String title, String summary, LocalDate date, String url, + String img, String source, Category cat, List tags) { + return new DataSourceDto(title, summary, date, url, img, source, cat, tags); + } + // create @Test @DisplayName("폴더 생성 성공- folderId=null 이면 default 폴더에 자료 생성") - void createDataSource_defaultFolder() { + void createDataSource_defaultFolder() throws IOException { int currentMemberId = 10; String sourceUrl = "https://example.com/a"; @@ -51,9 +66,23 @@ void createDataSource_defaultFolder() { .thenReturn(Optional.of(pa)); Folder defaultFolder = new Folder("default"); + ReflectionTestUtils.setField(defaultFolder, "id", 321); + when(folderRepository.findByArchiveIdAndIsDefaultTrue(anyInt())) .thenReturn(Optional.of(defaultFolder)); + when(tagRepository.findDistinctTagNamesByFolderId(eq(321))) + .thenReturn(List.of("AI", "Spring")); + + DataSourceDto returnedDto = dataSourceDto( + "제목A", "요약A", LocalDate.of(2025, 9, 1), sourceUrl, + "https://img.example/a.png", "example.com", Category.IT, + List.of("ML", "Infra") + ); + doReturn(returnedDto) + .when(dataProcessorService) + .process(eq(sourceUrl), anyList()); + when(dataSourceRepository.save(any(DataSource.class))) .thenAnswer(inv -> { DataSource ds = inv.getArgument(0); @@ -67,7 +96,7 @@ void createDataSource_defaultFolder() { @Test @DisplayName("폴더 생성 성공- folderId가 주어지면 해당 폴더에 자료 생성") - void createDataSource_specificFolder() { + void createDataSource_specificFolder() throws IOException { // given int currentMemberId = 10; String sourceUrl = "https://example.com/b"; @@ -78,6 +107,18 @@ void createDataSource_specificFolder() { when(folderRepository.findById(eq(folderId))).thenReturn(Optional.of(target)); + when(tagRepository.findDistinctTagNamesByFolderId(eq(folderId))) + .thenReturn(List.of("News", "Kotlin")); + + DataSourceDto returnedDto = dataSourceDto( + "제목B", "요약B", LocalDate.of(2025, 9, 2), sourceUrl, + "https://img.example/2.png", "tistory", Category.SCIENCE, + List.of("ML", "Infra") + ); + doReturn(returnedDto) + .when(dataProcessorService) + .process(eq(sourceUrl), anyList()); + when(dataSourceRepository.save(any(DataSource.class))) .thenAnswer(inv -> { DataSource ds = inv.getArgument(0); @@ -122,6 +163,149 @@ void createDataSource_defaultFolderNotFound() { ); } + //dataprocess 호출 테스트 + @Test + @DisplayName("자료 생성 성공 - 지정 폴더 + 컨텍스트 태그 수집 + process 호출 + DTO 매핑/태그 영속화") + void createDataSource_specificFolder_process_and_tags() throws Exception{ + // given + int currentMemberId = 10; + String sourceUrl = "https://example.com/b"; + Integer folderId = 77; + + Folder target = new Folder("target"); + ReflectionTestUtils.setField(target, "id", folderId); + + // 폴더 조회 + when(folderRepository.findById(eq(folderId))).thenReturn(Optional.of(target)); + // 컨텍스트 태그(distinct) + when(tagRepository.findDistinctTagNamesByFolderId(eq(folderId))) + .thenReturn(List.of("News", "Kotlin")); + // process 결과 DTO + DataSourceDto returnedDto = dataSourceDto( + "제목B", "요약B", LocalDate.of(2025, 9, 2), sourceUrl, + "https://img.example/2.png", "tistory", Category.SCIENCE, + List.of("ML", "Infra") + ); + when(dataProcessorService.process(eq(sourceUrl), anyList())).thenReturn(returnedDto); + + // save 캡처 + ArgumentCaptor dsCaptor = ArgumentCaptor.forClass(DataSource.class); + when(dataSourceRepository.save(dsCaptor.capture())) + .thenAnswer(inv -> { + DataSource ds = dsCaptor.getValue(); + ReflectionTestUtils.setField(ds, "id", 456); + return ds; + }); + + // when + int id = dataSourceService.createDataSource(currentMemberId, sourceUrl, folderId); + + // then + assertThat(id).isEqualTo(456); + + DataSource saved = dsCaptor.getValue(); + assertThat(saved.getFolder().getId()).isEqualTo(folderId); + assertThat(saved.getTitle()).isEqualTo("제목B"); + assertThat(saved.getSummary()).isEqualTo("요약B"); + assertThat(saved.getSourceUrl()).isEqualTo(sourceUrl); + assertThat(saved.getImageUrl()).isEqualTo("https://img.example/2.png"); + assertThat(saved.getSource()).isEqualTo("tistory"); + assertThat(saved.getCategory()).isEqualTo(Category.SCIENCE); + assertThat(saved.isActive()).isTrue(); + + // 태그 매핑 검증 + assertThat(saved.getTags()).hasSize(2); + assertThat(saved.getTags().stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("ML", "Infra"); + assertThat(saved.getTags().stream().allMatch(t -> t.getDataSource() == saved)).isTrue(); + + // 컨텍스트 태그가 process 에 전달되었는지 검증 + ArgumentCaptor> ctxTagsCaptor = ArgumentCaptor.forClass(List.class); + verify(dataProcessorService).process(eq(sourceUrl), ctxTagsCaptor.capture()); + assertThat(ctxTagsCaptor.getValue().stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("News", "Kotlin"); + + verify(tagRepository).findDistinctTagNamesByFolderId(folderId); + verifyNoInteractions(personalArchiveRepository); // 지정 폴더 경로이므로 호출 X + } + + // collectDistinctTagsOfFolder - tag 추출 단위 테스트 + + @Test + @DisplayName("태그 컨텍스트 수집 성공 - 폴더 하위 자료 태그명 distinct → Tag 리스트 변환") + void collectDistinctTagsOfFolder_success() { + // given + Integer folderId = 321; + when(tagRepository.findDistinctTagNamesByFolderId(eq(folderId))) + .thenReturn(List.of("AI", "Spring", "JPA")); + + // when (private 메서드 호출) + @SuppressWarnings("unchecked") + List ctxTags = (List) ReflectionTestUtils.invokeMethod( + dataSourceService, "collectDistinctTagsOfFolder", folderId + ); + + // then + assertThat(ctxTags).hasSize(3); + assertThat(ctxTags.stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("AI", "Spring", "JPA"); + assertThat(ctxTags.stream().allMatch(t -> t.getDataSource() == null)).isTrue(); + + verify(tagRepository).findDistinctTagNamesByFolderId(folderId); + } + + // buildDataSource 단위 테스트 + + @Test + @DisplayName("엔티티 빌드 성공 - process 호출 결과 DTO를 DataSource에 매핑 + 태그 양방향 세팅") + void buildDataSource_maps_dto_and_tags() throws Exception{ + // given + Folder folder = new Folder("f"); + ReflectionTestUtils.setField(folder, "id", 77); + String url = "https://example.com/x"; + + // 컨텍스트 태그(폴더 하위) - process 인자로만 사용됨 + List context = List.of(new Tag("Ctx1"), new Tag("Ctx2")); + + // process 결과 DTO + DataSourceDto returnedDto = dataSourceDto( + "T", "S", LocalDate.of(2025, 9, 1), url, + "https://img", "example.com", Category.IT, + List.of("A", "B") // DTO 태그 + ); + when(dataProcessorService.process(eq(url), anyList())).thenReturn(returnedDto); + + // when (private 메서드 호출) + DataSource ds = (DataSource) ReflectionTestUtils.invokeMethod( + dataSourceService, "buildDataSource", folder, url, context + ); + + // then + assertThat(ds).isNotNull(); + assertThat(ds.getFolder().getId()).isEqualTo(77); + assertThat(ds.getTitle()).isEqualTo("T"); + assertThat(ds.getSummary()).isEqualTo("S"); + assertThat(ds.getSourceUrl()).isEqualTo(url); + assertThat(ds.getImageUrl()).isEqualTo("https://img"); + assertThat(ds.getSource()).isEqualTo("example.com"); + assertThat(ds.getCategory()).isEqualTo(Category.IT); + assertThat(ds.isActive()).isTrue(); + + // 태그 매핑 검증 + assertThat(ds.getTags()).hasSize(2); + assertThat(ds.getTags().stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("A", "B"); + assertThat(ds.getTags().stream().allMatch(t -> t.getDataSource() == ds)).isTrue(); + + // process 호출시 컨텍스트 태그 전달 검증 + ArgumentCaptor> ctxTagsCaptor = ArgumentCaptor.forClass(List.class); + verify(dataProcessorService).process(eq(url), ctxTagsCaptor.capture()); + assertThat(ctxTagsCaptor.getValue().stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("Ctx1", "Ctx2"); + } + + + // delete @Test @DisplayName("단건 삭제 성공 - 존재하는 자료 삭제 시 ID 반환 (member 소유 확인)")