From c369402bf093aa7f1393caf5291f4767879efc86 Mon Sep 17 00:00:00 2001 From: "DESKTOP-N5KD4EV\\litte" Date: Thu, 25 Sep 2025 15:33:01 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor/OPS-255=20:=20datasource=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20sources=20=EC=B9=BC=EB=9F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zoopzoop/backend/domain/datasource/entity/DataSource.java | 3 +++ .../backend/domain/datasource/service/DataSourceService.java | 1 + 2 files changed, 4 insertions(+) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java index 8d9c3f7b..95db499b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java @@ -53,6 +53,9 @@ public class DataSource extends BaseEntity { @Column private String imageUrl; + // 자료 출처 URL + private String sources; + // 태그 목록 @OneToMany(mappedBy = "dataSource", cascade = CascadeType.ALL, orphanRemoval = true) private List tags = new ArrayList<>(); 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 3474eb10..05c92e55 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 @@ -55,6 +55,7 @@ private DataSource buildDataSource(String sourceUrl, Folder folder) { ds.setFolder(folder); ds.setSourceUrl(sourceUrl); ds.setTitle("자료 제목"); + ds.setSources("www.examplesource.com"); ds.setSummary("설명"); ds.setImageUrl("www.example.com/img"); ds.setDataCreatedDate(LocalDate.now()); From 68662ec8b6f5a37b587565df9e0a99b65ec10fbe Mon Sep 17 00:00:00 2001 From: "DESKTOP-N5KD4EV\\litte" Date: Fri, 26 Sep 2025 15:38:20 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor/OPS-319=20:=20=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../folder/controller/FolderController.java | 52 +-- .../folder/repository/FolderRepository.java | 13 + .../archive/folder/service/FolderService.java | 19 +- .../controller/DatasourceController.java | 49 ++- .../domain/datasource/dto/FileSummary.java | 7 +- .../repository/DataSourceRepository.java | 27 +- .../datasource/service/DataSourceService.java | 101 ++---- .../backend/global/security/StubAuthUtil.java | 30 +- .../controller/FolderControllerTest.java | 282 ++++++++------- .../folder/service/FolderServiceTest.java | 75 ++-- .../controller/DatasourceControllerTest.java | 332 +++++++++++------- .../service/DataSourceServiceTest.java | 155 ++++---- 12 files changed, 631 insertions(+), 511 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java index fe70cc3c..1897bf3b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java @@ -3,14 +3,16 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.resBodyForCreateFolder; import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.global.rsData.RsData; -import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; import java.util.HashMap; import java.util.List; @@ -28,13 +30,13 @@ public class FolderController { * @param rq reqBodyForCreateFolder * @return resBodyForCreateFolder */ - @PostMapping("") + @PostMapping public RsData createFolder( - @Valid @RequestBody reqBodyForCreateFolder rq + @Valid @RequestBody reqBodyForCreateFolder rq, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - // 임시 인증 정보 - Integer currentMemberId = StubAuthUtil.currentMemberId(); - FolderResponse createFile = folderService.createFolderForPersonal(currentMemberId, rq.folderName()); + Member member = userDetails.getMember(); + FolderResponse createFile = folderService.createFolderForPersonal(member.getId(), rq.folderName()); resBodyForCreateFolder rs = new resBodyForCreateFolder(createFile.folderName(), createFile.folderId()); @@ -43,7 +45,6 @@ public RsData createFolder( rq.folderName() + " 폴더가 생성됐습니다.", rs ); - } /** @@ -51,8 +52,12 @@ public RsData createFolder( * @param folderId 삭제할 folderId */ @DeleteMapping("/{folderId}") - public ResponseEntity> deleteFolder(@PathVariable Integer folderId) { - String deletedFolderName = folderService.deleteFolder(folderId); + public ResponseEntity> deleteFolder( + @PathVariable Integer folderId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + String deletedFolderName = folderService.deleteFolder(member.getId(), folderId); Map body = new HashMap<>(); body.put("status", 200); @@ -70,10 +75,12 @@ public ResponseEntity> deleteFolder(@PathVariable Integer fo @PatchMapping("/{folderId}") public ResponseEntity> updateFolderName( @PathVariable Integer folderId, - @RequestBody Map body + @RequestBody Map body, + @AuthenticationPrincipal CustomUserDetails userDetails ) { + Member member = userDetails.getMember(); String newName = body.get("folderName"); - String updatedName = folderService.updateFolderName(folderId, newName); + String updatedName = folderService.updateFolderName(member.getId(), folderId, newName); Map response = new HashMap<>(); response.put("status", 200); @@ -87,13 +94,12 @@ public ResponseEntity> updateFolderName( * 개인 아카이브의 폴더 이름 전부 조회 * "default", "폴더1", "폴더2" */ - @GetMapping("") - public ResponseEntity getFolders() { - // 로그인된 멤버 ID 가져오기 - Integer currentMemberId = StubAuthUtil.currentMemberId(); - - // 내 personal archive 안의 폴더 조회 - List folders = folderService.getFoldersForPersonal(currentMemberId); + @GetMapping + public ResponseEntity getFolders( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + List folders = folderService.getFoldersForPersonal(member.getId()); return ResponseEntity.ok( Map.of( @@ -108,10 +114,12 @@ public ResponseEntity getFolders() { * 폴더(내 PersonalArchive 소속) 안의 파일 목록 조회 */ @GetMapping("/{folderId}/files") - public ResponseEntity getFilesInFolder(@PathVariable Integer folderId) { - Integer currentMemberId = StubAuthUtil.currentMemberId(); - - FolderFilesDto rs = folderService.getFilesInFolderForPersonal(currentMemberId, folderId); + public ResponseEntity getFilesInFolder( + @PathVariable Integer folderId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + FolderFilesDto rs = folderService.getFilesInFolderForPersonal(member.getId(), folderId); return ResponseEntity.ok( Map.of( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java index cd3b30b6..9f5048c1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; @@ -45,4 +46,16 @@ public interface FolderRepository extends JpaRepository{ and f.isDefault = true """) Optional findDefaultFolderByMemberId(Integer memberId); + + // 한 번의 조인으로 존재 + 소유권(memberId) 검증 + @Query(""" + select f + from Folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where f.id = :folderId + and pa.member.id = :memberId + """) + Optional findByIdAndMemberId(@Param("folderId") Integer folderId, + @Param("memberId") Integer memberId); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java index 849ace79..09c8aebe 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java @@ -38,9 +38,8 @@ public class FolderService { */ @Transactional public FolderResponse createFolderForPersonal(Integer currentMemberId, String folderName) { - if (folderName == null || folderName.trim().isEmpty()) { + if (folderName == null || folderName.trim().isEmpty()) throw new IllegalArgumentException("폴더 이름은 비어 있을 수 없습니다."); - } Member member = memberRepository.findById(currentMemberId) .orElseThrow(() -> new IllegalArgumentException("멤버를 찾을 수 없습니다.")); @@ -115,8 +114,9 @@ private static String pickNextAvailable(String file, List existing) { * soft delete 아직 구현 X */ @Transactional - public String deleteFolder(Integer folderId) { - Folder folder = folderRepository.findById(folderId) + public String deleteFolder(Integer currentId, Integer folderId) { + // 공격자에게 리소스 존재 여부를 노출 X (존재하지 않음 / 남의 폴더) + Folder folder = folderRepository.findByIdAndMemberId(folderId, currentId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); if (folder.isDefault()) @@ -131,8 +131,8 @@ public String deleteFolder(Integer folderId) { * folderId에 해당하는 이름 변경 */ @Transactional - public String updateFolderName(Integer folderId, String newName) { - Folder folder = folderRepository.findById(folderId) + public String updateFolderName(Integer currentId, Integer folderId, String newName) { + Folder folder = folderRepository.findByIdAndMemberId(folderId, currentId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); // 같은 아카이브 내에서 중복 폴더 이름 확인 @@ -173,18 +173,19 @@ public List getFoldersForPersonal(Integer memberId) { */ @Transactional(readOnly = true) public FolderFilesDto getFilesInFolderForPersonal(Integer memberId, Integer folderId) { - Folder folder = folderRepository.findById(folderId) + Folder folder = folderRepository.findByIdAndMemberId(folderId, memberId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); var files = dataSourceRepository.findAllByFolder(folder).stream() .map(ds -> new FileSummary( ds.getId(), ds.getTitle(), - ds.getCreateDate(), // LocalDateTime + ds.getDataCreatedDate(), // LocalDate ds.getSummary(), ds.getSourceUrl(), ds.getImageUrl(), - ds.getTags() == null ? List.of() : ds.getTags() + ds.getTags() == null ? List.of() : ds.getTags(), + ds.getCategory() == null ? null : ds.getCategory().toString() )) .toList(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java index a7d3296a..66bc6ff6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java @@ -3,10 +3,12 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.tuna.zoopzoop.backend.domain.datasource.dto.*; import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; -import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; import java.util.HashMap; import java.util.Map; @@ -24,10 +26,14 @@ public class DatasourceController { * folderId 등록될 폴더 위치(null 이면 default) */ @PostMapping("") - public ResponseEntity createDataSource(@Valid @RequestBody reqBodyForCreateDataSource rq) { + public ResponseEntity createDataSource( + @Valid @RequestBody reqBodyForCreateDataSource rq, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + // 로그인된 멤버 Id 사용 + Member member = userDetails.getMember(); + Integer currentMemberId = member.getId(); - //임시 인증 정보 - Integer currentMemberId = StubAuthUtil.currentMemberId(); int rs = dataSourceService.createDataSource(currentMemberId, rq.sourceUrl(), rq.folderId()); return ResponseEntity.ok() .body( @@ -39,8 +45,12 @@ public ResponseEntity createDataSource(@Valid @RequestBody reqBodyForCreateDa * 자료 단건 삭제 */ @DeleteMapping("/{dataSourceId}") - public ResponseEntity> delete(@PathVariable Integer dataSourceId) { - int deletedId = dataSourceService.deleteById(dataSourceId); + public ResponseEntity> delete( + @PathVariable Integer dataSourceId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + int deletedId = dataSourceService.deleteById(member.getId(), dataSourceId); return ResponseEntity.ok( Map.of( "status", 200, @@ -55,11 +65,12 @@ public ResponseEntity> delete(@PathVariable Integer dataSour */ @PostMapping("/delete") public ResponseEntity> deleteMany( - @Valid @RequestBody reqBodyForDeleteMany body + @Valid @RequestBody reqBodyForDeleteMany body, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - dataSourceService.deleteMany(body.dataSourceId()); + Member member = userDetails.getMember(); + dataSourceService.deleteMany(member.getId(), body.dataSourceId()); - // Map.of 는 null 불가 → LinkedHashMap 사용 Map res = new java.util.LinkedHashMap<>(); res.put("status", 200); res.put("msg", "복수개의 자료가 삭제됐습니다."); @@ -75,9 +86,11 @@ public ResponseEntity> deleteMany( @PatchMapping("/{dataSourceId}/move") public ResponseEntity moveDataSource( @PathVariable Integer dataSourceId, - @Valid @RequestBody reqBodyForMoveDataSource rq + @Valid @RequestBody reqBodyForMoveDataSource rq, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - Integer currentMemberId = StubAuthUtil.currentMemberId(); + Member member = userDetails.getMember(); + Integer currentMemberId = member.getId(); DataSourceService.MoveResult result = dataSourceService.moveDataSource(currentMemberId, dataSourceId, rq.folderId()); @@ -101,8 +114,12 @@ public ResponseEntity moveDataSource( * 자료 다건 이동 */ @PatchMapping("/move") - public ResponseEntity moveMany(@Valid @RequestBody reqBodyForMoveMany rq) { - Integer currentMemberId = StubAuthUtil.currentMemberId(); + public ResponseEntity moveMany( + @Valid @RequestBody reqBodyForMoveMany rq, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + Integer currentMemberId = member.getId(); dataSourceService.moveDataSources(currentMemberId, rq.folderId(), rq.dataSourceId()); @@ -122,7 +139,8 @@ public ResponseEntity moveMany(@Valid @RequestBody reqBodyForMoveMany rq) { @PatchMapping("/{dataSourceId}") public ResponseEntity updateDataSource( @PathVariable Integer dataSourceId, - @Valid @RequestBody reqBodyForUpdateDataSource body + @Valid @RequestBody reqBodyForUpdateDataSource body, + @AuthenticationPrincipal CustomUserDetails userDetails ) { // title, summary 둘 다 비어있으면 의미 없는 요청 → 400 boolean noTitle = (body.title() == null || body.title().isBlank()); @@ -131,7 +149,8 @@ public ResponseEntity updateDataSource( throw new IllegalArgumentException("변경할 값이 없습니다. title 또는 summary 중 하나 이상을 전달하세요."); } - Integer updatedId = dataSourceService.updateDataSource(dataSourceId, body.title(), body.summary()); + Member member = userDetails.getMember(); + Integer updatedId = dataSourceService.updateDataSource(member.getId(), dataSourceId, body.title(), body.summary()); // CHANGED String msg = updatedId + "번 자료가 수정됐습니다."; return ResponseEntity.ok( new ApiResponse<>(200, msg, new resBodyForUpdateDataSource(updatedId)) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java index 1efc5084..66c9857a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java @@ -2,15 +2,16 @@ import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; public record FileSummary( Integer dataSourceId, String title, - LocalDateTime createdAt, + LocalDate createdAt, String summary, String sourceUrl, String imageUrl, - List tags + List tags, + String category ) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java index 2a4156b6..fcbf8a65 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java @@ -9,14 +9,35 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; @Repository public interface DataSourceRepository extends JpaRepository { List findAllByFolder(Folder folder); - @Query("select d.id from DataSource d where d.id in ?1") - java.util.List findExistingIds(Collection ids); - List findAllByIdIn(Collection ids); + + // CHANGED: 특정 멤버(개인 아카이브 소유자) 범위에서 id로 조회 (ownership check) + @Query(""" + select d from DataSource d + join d.folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where d.id = :id + and pa.member.id = :memberId + """) + Optional findByIdAndMemberId(@Param("id") Integer id, @Param("memberId") Integer memberId); + + // CHANGED: 여러 id 중에서 해당 member 소유인 id만 반환 (다건 삭제/검증용) + @Query(""" + select d.id from DataSource d + join d.folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where pa.member.id = :memberId + and d.id in :ids + """) + List findExistingIdsInMember(@Param("memberId") Integer memberId, @Param("ids") Collection ids); + } 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 05c92e55..77da2834 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,6 +8,7 @@ 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.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; @@ -24,32 +25,22 @@ public class DataSourceService { /** * 지정한 folder 위치에 자료 생성 - * @param currentMemberId 현재 로그인한 유저 Id - * @param sourceUrl 생성할 자료의 url - * @param folderId 생성될 폴더 위치 Id */ @Transactional public int createDataSource(int currentMemberId, String sourceUrl, Integer folderId) { Folder folder; - // default 폴더에 데이터 넣을 경우 if(folderId == null) folder = findDefaultFolder(currentMemberId); - // Id에 해당하는 폴더에 데이터 넣을 경우 else folder = folderRepository.findById(folderId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); - // 임시 파일 생성 메서드 DataSource ds = buildDataSource(sourceUrl, folder); DataSource saved = dataSourceRepository.save(ds); return saved.getId(); } - /** - * 임시 data build 메서드 - * 추후 title,summary, tag, category, imgUrl 불러올 예정 - */ private DataSource buildDataSource(String sourceUrl, Folder folder) { DataSource ds = new DataSource(); ds.setFolder(folder); @@ -59,101 +50,79 @@ private DataSource buildDataSource(String sourceUrl, Folder folder) { ds.setSummary("설명"); ds.setImageUrl("www.example.com/img"); ds.setDataCreatedDate(LocalDate.now()); + ds.setCategory(Category.IT); ds.setActive(true); return ds; } - /** - * default 폴더에 해당하는 FolderId 반환 - * folder의 isDefault 속성 + 인덱스(archiveId)로 탐색 - */ private Folder findDefaultFolder(int currentMemberId) { - // 현재 로그인 Id 기반 Personal Archive Id 탐색 PersonalArchive pa = personalArchiveRepository.findByMemberId(currentMemberId) .orElseThrow(() -> new NoResultException("개인 아카이브를 찾을 수 없습니다.")); - // 2. PersonalArchive 안에 연결된 Archive 조회 Integer archiveId = pa.getArchive().getId(); - // 3. 해당 Archive 내 default 폴더 조회 return folderRepository.findByArchiveIdAndIsDefaultTrue(archiveId) .orElseThrow(() -> new NoResultException("default 폴더를 찾을 수 없습니다.")); } /** - * 자료 단건 삭제 - * soft delete 추후 구현 예정 - * @param dataSourceId 삭제할 자료 Id + * 자료 단건 삭제 (소유자 검증 포함) // CHANGED */ @Transactional - public int deleteById(Integer dataSourceId) { - DataSource ds = dataSourceRepository.findById(dataSourceId) + public int deleteById(Integer memberId, Integer dataSourceId) { + // member 범위에서 자료를 조회하여 소유 확인 + DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); - /* 추후 권한 체크 예외 필요 */ - dataSourceRepository.delete(ds); return dataSourceId; } /** - * 자료 다건 삭제 - * 모든 자료 id가 존재해야 함 (부분 존재 시 404) + * 자료 다건 삭제 (모든 id가 해당 멤버 소유여야 함) // CHANGED */ @Transactional - public void deleteMany(List ids) { + public void deleteMany(Integer memberId, List ids) { if (ids == null || ids.isEmpty()) { throw new IllegalArgumentException("삭제할 자료 id 배열이 비어있습니다."); } - // 존재 여부 검증 (부분 존재 시 누락 ID 명시) - List existing = dataSourceRepository.findExistingIds(ids); + // 해당 멤버가 소유한 id만 조회 + List existing = dataSourceRepository.findExistingIdsInMember(memberId, ids); if (existing.size() != ids.size()) { Set missing = new HashSet<>(ids); missing.removeAll(new HashSet<>(existing)); - throw new NoResultException("존재하지 않는 자료 ID 포함: " + missing); + throw new NoResultException("존재하지 않거나 소유자가 다른 자료 ID 포함: " + missing); } dataSourceRepository.deleteAllByIdInBatch(ids); } /** - * 자료 위치 단건 이동 + * 자료 위치 단건 이동 (현재 로직은 동일하되 이미 소유 확인이 필요한 경우 검증 추가 가능) */ @Transactional public MoveResult moveDataSource(Integer currentMemberId, Integer dataSourceId, Integer targetFolderId) { - // 자료 확인 - DataSource ds = dataSourceRepository.findById(dataSourceId) + // 자료 확인: 먼저 멤버 소유인지 확인 (안하면 타인 자료 이동 위험) + DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, currentMemberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); Folder targetFolder = resolveTargetFolder(currentMemberId, targetFolderId); - // 동일 폴더로 이동 요청 -> 통과 if (ds.getFolder().getId() == targetFolder.getId()) return new MoveResult(ds.getId(), targetFolder.getId()); - // 목적지 폴더 내 파일명 중복 확인 -// if (dataSourceRepository.existsByFolder_IdAndTitle(targetFolderId, ds.getTitle())) -// throw new IllegalStateException("해당 폴더에 동일한 제목의 자료가 이미 존재합니다."); - ds.setFolder(targetFolder); return new MoveResult(ds.getId(), targetFolder.getId()); } - - - /** - * 자료 위치 다건 이동 - */ @Transactional public void moveDataSources(Integer currentMemberId, Integer targetFolderId, List dataSourceIds) { - // 1) 요소 null 검증 (서비스 방어) if (dataSourceIds.stream().anyMatch(Objects::isNull)) throw new IllegalArgumentException("자료 id 목록에 null이 포함되어 있습니다."); - // 자료 Id 중복 확인 Map counts = dataSourceIds.stream() .collect(Collectors.groupingBy(id -> id, Collectors.counting())); List duplicates = counts.entrySet().stream() @@ -165,49 +134,30 @@ public void moveDataSources(Integer currentMemberId, Integer targetFolderId, Lis throw new IllegalArgumentException("같은 자료를 두 번 선택했습니다: " + duplicates); } - // 목적지 폴더 확인 Folder targetFolder = resolveTargetFolder(currentMemberId, targetFolderId); - // 목록의 각 자료 확인 + // 소유 검증: 요청된 id들이 모두 현재 멤버의 소유인지 확인 + List existing = dataSourceRepository.findExistingIdsInMember(currentMemberId, dataSourceIds); + if (existing.size() != dataSourceIds.size()) { + Set missing = new HashSet<>(dataSourceIds); + missing.removeAll(new HashSet<>(existing)); + throw new NoResultException("존재하지 않거나 소유자가 다른 자료 ID 포함: " + missing); + } + List list = dataSourceRepository.findAllByIdIn(dataSourceIds); if (list.size() != dataSourceIds.size()) throw new NoResultException("요청한 자료 중 존재하지 않는 항목이 있습니다."); - // 목적지 폴더 추출 List needMove = list.stream() .filter(ds -> !Objects.equals(ds.getFolder().getId(), targetFolder.getId())) .toList(); - // 이미 모두 이동한 경우 if (needMove.isEmpty()) return; - // 같은 이름의 자료 여러 개 이동 시 충돌 - /* - Map reqTitleCount = needMove.stream() - .collect(Collectors.groupingBy(DataSource::getTitle, Collectors.counting())); - List internalDup = reqTitleCount.entrySet().stream() - .filter(e -> e.getValue() > 1) - .map(Map.Entry::getKey) - .toList(); - if (!internalDup.isEmpty()) { - throw new IllegalStateException("요청 목록 내부에 중복 제목이 포함되어 있습니다: " + internalDup); - } - - 이동할 폴더에 이미 같은 제목이 존재하는지 확인 - List titles = needMove.stream().map(DataSource::getTitle).toList(); - List conflicts = titles.isEmpty() - ? List.of() - : dataSourceRepository.findExistingTitlesInFolder(targetFolderId, titles); - - if (!conflicts.isEmpty()) { - throw new IllegalStateException("대상 폴더에 이미 존재하는 제목이 있어 이동할 수 없습니다: " + conflicts); - } - */ needMove.forEach(ds -> ds.setFolder(targetFolder)); } - // 대상 폴더 해석 private Folder resolveTargetFolder(Integer currentMemberId, Integer targetFolderId) { if (targetFolderId == null) { return folderRepository.findDefaultFolderByMemberId(currentMemberId) @@ -217,8 +167,11 @@ private Folder resolveTargetFolder(Integer currentMemberId, Integer targetFolder .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); } - public Integer updateDataSource(Integer dataSourceId, String newTitle, String newSummary) { - DataSource ds = dataSourceRepository.findById(dataSourceId) + /** + * 자료 수정 (소유자 검증 포함) // CHANGED + */ + public Integer updateDataSource(Integer memberId, Integer dataSourceId, String newTitle, String newSummary) { + DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); if (newTitle != null && !newTitle.isBlank()) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java index 2135ee69..787d2509 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java @@ -1,15 +1,15 @@ -package org.tuna.zoopzoop.backend.global.security; - -/** - * Spring Securiity 구현 전 임시 헬퍼 클래스 - * 추후 Spring Security 연동시 SecurityContext에서 불러오도록 수정 - */ - -public final class StubAuthUtil { - private StubAuthUtil() {} - - public static Integer currentMemberId() { - return 1; - } - -} +//package org.tuna.zoopzoop.backend.global.security; +// +///** +// * Spring Securiity 구현 전 임시 헬퍼 클래스 +// * 추후 Spring Security 연동시 SecurityContext에서 불러오도록 수정 +// */ +// +//public final class StubAuthUtil { +// private StubAuthUtil() {} +// +// public static Integer currentMemberId() { +// return 1; +// } +// +//} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java index f09e2569..95fa1504 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java @@ -1,114 +1,169 @@ package org.tuna.zoopzoop.backend.domain.archive.folder.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.NoResultException; -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.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.*; +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.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; +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.datasource.dto.FileSummary; -import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +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.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; -import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; -import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +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; -import java.util.HashMap; +import java.time.LocalDate; import java.util.List; -import java.util.Map; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@ExtendWith(MockitoExtension.class) -@Transactional +/** + * FolderController 통합 테스트 (Given / When / Then 주석 유지) + * + * - @SpringBootTest + @AutoConfigureMockMvc 로 전체 컨텍스트에서 테스트 + * - @WithUserDetails 를 사용해 인증 principal 을 주입 + * - 테스트용 멤버는 BeforeAll에서 생성 (UserDetailsService 가 해당 username 으로 로드 가능해야 함) + */ @ActiveProfiles("test") -class FolderControllerTest { +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FolderControllerIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @Autowired private MemberService memberService; + @Autowired private MemberRepository memberRepository; + + @Autowired private FolderService folderService; + @Autowired private FolderRepository folderRepository; + + @Autowired private DataSourceRepository dataSourceRepository; - @Mock private FolderService folderService; + private final String TEST_PROVIDER_KEY = "sc1111"; // WithUserDetails 에서 사용되는 provider key ("KAKAO:sc1111") + private Integer testMemberId; + private Integer docsFolderId; - private MockMvc mockMvc; - private ObjectMapper objectMapper; + @BeforeAll + void beforeAll() { + // WithUserDetails가 SecurityContext 생성 시 DB에서 사용자를 조회하므로 미리 생성 + try { + memberService.createMember("folderTester", "http://example.com/profile.png", TEST_PROVIDER_KEY, Provider.KAKAO); + } catch (Exception ignored) {} + + // 준비된 멤버 ID + testMemberId = memberRepository.findByProviderAndProviderKey(Provider.KAKAO, TEST_PROVIDER_KEY) + .map(m -> m.getId()) + .orElseThrow(); + + // GIVEN: 테스트용 폴더 및 샘플 자료 준비 (docs 폴더 + 2개 자료) + FolderResponse fr = folderService.createFolderForPersonal(testMemberId, "docs"); + docsFolderId = fr.folderId(); + + Folder docsFolder = folderRepository.findById(docsFolderId).orElseThrow(); + + // 자료 2건 생성 — **category는 NOT NULL enum** 이므로 반드시 설정 + DataSource d1 = new DataSource(); + d1.setFolder(docsFolder); + d1.setTitle("spec.pdf"); + d1.setSummary("요약 A"); + d1.setSourceUrl("http://src/a"); + d1.setImageUrl("http://img/a"); + d1.setDataCreatedDate(LocalDate.now()); + d1.setActive(true); + d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); + d1.setCategory(Category.IT); // enum 타입 반영 + dataSourceRepository.save(d1); + + DataSource d2 = new DataSource(); + d2.setFolder(docsFolder); + d2.setTitle("notes.txt"); + d2.setSummary("요약 B"); + d2.setSourceUrl("http://src/b"); + d2.setImageUrl("http://img/b"); + d2.setDataCreatedDate(LocalDate.now()); + d2.setActive(true); + d2.setTags(List.of()); + d2.setCategory(Category.SCIENCE); + dataSourceRepository.save(d2); + } - @BeforeEach - void setUp() { - FolderController controller = new FolderController(folderService); - mockMvc = MockMvcBuilders.standaloneSetup(controller) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); - objectMapper = new ObjectMapper(); + @AfterAll + void afterAll() { + // 테스트용 회원 삭제 (cascade에 따라 연결된 엔티티 정리) + memberRepository.findByProviderAndProviderKey(Provider.KAKAO, TEST_PROVIDER_KEY) + .ifPresent(memberRepository::delete); } // CreateFile @Test @DisplayName("개인 아카이브 폴더 생성 - 성공 시 200과 응답 DTO 반환") + @WithUserDetails("KAKAO:sc1111") void createFolder_ok() throws Exception { - // given - when(folderService.createFolderForPersonal(anyInt(), eq("보고서"))) - .thenReturn(new FolderResponse(123, "보고서")); + // Given var req = new reqBodyForCreateFolder("보고서"); - // when & then + // When & Then mockMvc.perform(post("/api/v1/archive/folder") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("200")) .andExpect(jsonPath("$.msg").value("보고서 폴더가 생성됐습니다.")) - .andExpect(jsonPath("$.data.folderId").value(123)) + .andExpect(jsonPath("$.data.folderId").isNumber()) .andExpect(jsonPath("$.data.folderName").value("보고서")); } @Test @DisplayName("개인 아카이브 폴더 생성 - 폴더 이름 누락 시 400") + @WithUserDetails("KAKAO:sc1111") void createFolder_missingName() throws Exception { - // given + // Given var req = new reqBodyForCreateFolder(null); - // when & then + // When & Then mockMvc.perform(post("/api/v1/archive/folder") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isBadRequest()); } - // DeleteFile @Test @DisplayName("개인 아카이브 폴더 삭제 - 성공 시 200과 삭제 메시지 반환") + @WithUserDetails("KAKAO:sc1111") void deleteFolder_ok() throws Exception { - // given - when(folderService.deleteFolder(7)).thenReturn("보고서"); + // Given: 새 폴더 생성 후 삭제 준비 + FolderResponse fr = folderService.createFolderForPersonal(testMemberId, "todelete"); + Integer idToDelete = fr.folderId(); - // when & then - mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", 7)) + // When & Then + mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", idToDelete)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value("보고서 폴더가 삭제됐습니다.")); + .andExpect(jsonPath("$.msg").value("todelete 폴더가 삭제됐습니다.")); } @Test @DisplayName("개인 아카이브 폴더 삭제 - 존재하지 않으면 404") + @WithUserDetails("KAKAO:sc1111") void deleteFolder_notFound() throws Exception { - // given - when(folderService.deleteFolder(404)) - .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); - - // when & then - mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", 404)) + // When & Then + mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", 999999)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value("404")) .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); @@ -117,15 +172,17 @@ void deleteFolder_notFound() throws Exception { // UpdateFile @Test @DisplayName("개인 아카이브 폴더 이름 변경 - 성공 시 200과 변경된 이름 반환") + @WithUserDetails("KAKAO:sc1111") void updateFolder_ok() throws Exception { - // given - when(folderService.updateFolderName(10, "회의록")).thenReturn("회의록"); + // Given: rename 대상 폴더 생성 + FolderResponse fr = folderService.createFolderForPersonal(testMemberId, "toRename"); + Integer id = fr.folderId(); - Map body = new HashMap<>(); - body.put("folderName", "회의록"); + var body = new java.util.HashMap(); + body.put("folderName","회의록"); - // when & then - mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", 10) + // When & Then + mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", id) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) @@ -136,17 +193,14 @@ void updateFolder_ok() throws Exception { @Test @DisplayName("개인 아카이브 폴더 이름 변경 - 존재하지 않는 폴더면 404") + @WithUserDetails("KAKAO:sc1111") void updateFolder_notFound() throws Exception { - // given - when(folderService.updateFolderName(99, "회의록")) - .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); - - - Map body = new HashMap<>(); - body.put("folderName", "회의록"); + // Given + var body = new java.util.HashMap(); + body.put("folderName","회의록"); - // when & then - mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", 99) + // When & Then + mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", 999999) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()) @@ -158,82 +212,48 @@ void updateFolder_notFound() throws Exception { // Read: 내 폴더 목록 @Test @DisplayName("개인 아카이브 폴더 목록 조회 - 성공") + @WithUserDetails("KAKAO:sc1111") void getFolders_success() throws Exception { - List folders = List.of( - new FolderResponse(1, "default"), - new FolderResponse(2, "docs") - ); - - try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { - mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); - when(folderService.getFoldersForPersonal(100)).thenReturn(folders); - - mockMvc.perform(get("/api/v1/archive/folder") - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value("개인 아카이브의 폴더 목록을 불러왔습니다.")) - .andExpect(jsonPath("$.data.folders.length()").value(2)) - .andExpect(jsonPath("$.data.folders[0].folderId").value(1)) - .andExpect(jsonPath("$.data.folders[0].folderName").value("default")) - .andExpect(jsonPath("$.data.folders[1].folderName").value("docs")); - } + // When & Then + mockMvc.perform(get("/api/v1/archive/folder") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("개인 아카이브의 폴더 목록을 불러왔습니다.")) + .andExpect(jsonPath("$.data.folders").isArray()); } // Read: 폴더 내 파일 목록 @Test @DisplayName("폴더 내 파일 목록 조회 - 성공") + @WithUserDetails("KAKAO:sc1111") void getFilesInFolder_success() throws Exception { - // given - FolderFilesDto rs = new FolderFilesDto( - 2, "docs", - List.of( - new FileSummary(10, "spec.pdf", null, "요약 A", "http://src/a", "http://img/a", - List.of(new Tag("tag1"), new Tag("tag2"))), - new FileSummary(11, "notes.txt", null, "요약 B", "http://src/b", "http://img/b", - List.of()) - ) - ); - - try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { - mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); - when(folderService.getFilesInFolderForPersonal(100, 2)).thenReturn(rs); - - // when & then - mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", 2) - .accept(MediaType.APPLICATION_JSON)) - // then - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value("해당 폴더의 파일 목록을 불러왔습니다.")) - .andExpect(jsonPath("$.data.files").isArray()) - .andExpect(jsonPath("$.data.files.length()").value(2)) - .andExpect(jsonPath("$.data.files[0].dataSourceId").value(10)) - .andExpect(jsonPath("$.data.files[0].title").value("spec.pdf")) - .andExpect(jsonPath("$.data.files[0].summary").value("요약 A")) - .andExpect(jsonPath("$.data.files[0].sourceUrl").value("http://src/a")) - .andExpect(jsonPath("$.data.files[0].imageUrl").value("http://img/a")) - .andExpect(jsonPath("$.data.files[0].tags[0].tagName").value("tag1")); - } + // Given : @BeforeAll: docsFolderId 및 샘플 파일 준비됨 + + // When & Then + mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", docsFolderId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("해당 폴더의 파일 목록을 불러왔습니다.")) + .andExpect(jsonPath("$.data.files").isArray()) + .andExpect(jsonPath("$.data.files.length()").value(greaterThanOrEqualTo(2))) + .andExpect(jsonPath("$.data.files[0].dataSourceId").isNumber()) + .andExpect(jsonPath("$.data.files[0].title").isString()) + .andExpect(jsonPath("$.data.files[0].summary").isString()) + .andExpect(jsonPath("$.data.files[0].sourceUrl").isString()) + .andExpect(jsonPath("$.data.files[0].imageUrl").isString()); } @Test @DisplayName("폴더 내 파일 목록 조회 - 폴더가 없으면 404") + @WithUserDetails("KAKAO:sc1111") void getFilesInFolder_notFound() throws Exception { - // given - try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { - mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); - when(folderService.getFilesInFolderForPersonal(100, 999)) - .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); - - // when & then - mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", 999) - .accept(MediaType.APPLICATION_JSON)) - // then - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value("404")) - .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); - } + // When & Then + mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", 999999) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); } - } 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 41d34372..394a939a 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,8 +17,8 @@ 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.dto.FileSummary; 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; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; @@ -33,6 +33,10 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +/** + * FolderService 단위 테스트 + * - memberRepository 스텁은 필요한 테스트에만 선언 + */ @ExtendWith(MockitoExtension.class) @Transactional @ActiveProfiles("test") @@ -51,6 +55,7 @@ class FolderServiceTest { @BeforeEach void setUp() { + // 공통 테스트 데이터 준비 (스텁은 각 테스트에서 선언) this.member = new Member(); ReflectionTestUtils.setField(member, "id", 1); @@ -67,10 +72,11 @@ void setUp() { @Test @DisplayName("폴더 생성 성공(중복 없음)") void createFolder_success() { - when(memberRepository.findById(1)).thenReturn(Optional.of(member)); + // GIVEN + when(memberRepository.findById(1)).thenReturn(Optional.of(member)); // <- 반드시 필요 when(personalArchiveRepository.findByMemberId(1)).thenReturn(Optional.of(personalArchive)); when(folderRepository.findNamesForConflictCheck(eq(archive.getId()), anyString(), anyString())) - .thenReturn(List.of()); // 충돌 없음 + .thenReturn(List.of()); Folder saved = new Folder(); saved.setName("보고서"); @@ -79,8 +85,10 @@ void createFolder_success() { when(folderRepository.save(any(Folder.class))).thenReturn(saved); + // WHEN FolderResponse result = folderService.createFolderForPersonal(1, "보고서"); + // THEN assertThat(result.folderId()).isEqualTo(999); assertThat(result.folderName()).isEqualTo("보고서"); } @@ -88,29 +96,34 @@ void createFolder_success() { @Test @DisplayName("폴더 이름 중복 시 '(1)' 붙여 생성") void createFolder_withConflict() { - when(memberRepository.findById(1)).thenReturn(Optional.of(member)); + // given + when(memberRepository.findById(1)).thenReturn(Optional.of(member)); // <- 반드시 필요 when(personalArchiveRepository.findByMemberId(1)).thenReturn(Optional.of(personalArchive)); when(folderRepository.findNamesForConflictCheck(eq(archive.getId()), eq("보고서"), anyString())) .thenReturn(List.of("보고서")); Folder saved = new Folder(); - saved.setName("보고서(1)"); + saved.setName("보고서 (1)"); saved.setArchive(archive); ReflectionTestUtils.setField(saved, "id", 1000); when(folderRepository.save(any(Folder.class))).thenReturn(saved); + // when FolderResponse result = folderService.createFolderForPersonal(1, "보고서"); - assertThat(result.folderName()).isEqualTo("보고서(1)"); + // then + assertThat(result.folderName()).isEqualTo("보고서 (1)"); assertThat(result.folderId()).isEqualTo(1000); } @Test @DisplayName("멤버가 없으면 예외 발생") void createFolder_memberNotFound() { + // given when(memberRepository.findById(2)).thenReturn(Optional.empty()); + // when & then assertThrows(IllegalArgumentException.class, () -> folderService.createFolderForPersonal(2, "보고서")); } @@ -119,15 +132,18 @@ void createFolder_memberNotFound() { @Test @DisplayName("폴더 삭제 성공") void deleteFolder_success() { + // given Folder folder = new Folder(); folder.setName("보고서"); folder.setArchive(archive); ReflectionTestUtils.setField(folder, "id", 500); - when(folderRepository.findById(500)).thenReturn(Optional.of(folder)); + when(folderRepository.findByIdAndMemberId(500, 1)).thenReturn(Optional.of(folder)); - String deletedName = folderService.deleteFolder(500); + // when + String deletedName = folderService.deleteFolder(1, 500); + // then assertThat(deletedName).isEqualTo("보고서"); verify(folderRepository, times(1)).delete(folder); } @@ -135,21 +151,25 @@ void deleteFolder_success() { @Test @DisplayName("폴더 삭제 실패 - 존재하지 않는 폴더") void deleteFolder_notFound() { - when(folderRepository.findById(999)).thenReturn(Optional.empty()); + // given + when(folderRepository.findByIdAndMemberId(999, 1)).thenReturn(Optional.empty()); - assertThrows(NoResultException.class, () -> folderService.deleteFolder(999)); + // when & then + assertThrows(NoResultException.class, () -> folderService.deleteFolder(1, 999)); verify(folderRepository, never()).delete(any(Folder.class)); } @Test @DisplayName("default 폴더는 삭제할 수 없다") void deleteFolder_default_forbidden() { + // given Folder defaultFolder = new Folder("default"); // isDefault=true ReflectionTestUtils.setField(defaultFolder, "id", 42); - when(folderRepository.findById(42)).thenReturn(Optional.of(defaultFolder)); + when(folderRepository.findByIdAndMemberId(42, 1)).thenReturn(Optional.of(defaultFolder)); - assertThrows(IllegalArgumentException.class, () -> folderService.deleteFolder(42)); + // when & then + assertThrows(IllegalArgumentException.class, () -> folderService.deleteFolder(1, 42)); verify(folderRepository, never()).delete(any()); } @@ -157,16 +177,21 @@ void deleteFolder_default_forbidden() { @Test @DisplayName("폴더 이름 변경 성공") void updateFolderName_success() { + // given Folder folder = new Folder(); folder.setName("기존이름"); folder.setArchive(archive); ReflectionTestUtils.setField(folder, "id", 700); - when(folderRepository.findById(700)).thenReturn(Optional.of(folder)); + when(folderRepository.findByIdAndMemberId(700, 1)).thenReturn(Optional.of(folder)); + when(folderRepository.findNamesForConflictCheck(archive.getId(), "새이름", folder.getName())) + .thenReturn(List.of()); when(folderRepository.save(any(Folder.class))).thenAnswer(invocation -> invocation.getArgument(0)); - String updated = folderService.updateFolderName(700, "새이름"); + // when + String updated = folderService.updateFolderName(1, 700, "새이름"); + // then assertThat(updated).isEqualTo("새이름"); assertThat(folder.getName()).isEqualTo("새이름"); verify(folderRepository, times(1)).save(folder); @@ -175,26 +200,30 @@ void updateFolderName_success() { @Test @DisplayName("폴더 이름 변경 실패 - 존재하지 않음") void updateFolderName_notFound() { - when(folderRepository.findById(701)).thenReturn(Optional.empty()); + // given + when(folderRepository.findByIdAndMemberId(701, 1)).thenReturn(Optional.empty()); - assertThrows(NoResultException.class, () -> folderService.updateFolderName(701, "아무거나")); + // when & then + assertThrows(NoResultException.class, () -> folderService.updateFolderName(1, 701, "아무거나")); verify(folderRepository, never()).save(any(Folder.class)); } @Test @DisplayName("폴더 이름 변경 실패 - 중복 이름 존재") void updateFolderName_conflict() { + // given Folder folder = new Folder(); folder.setName("기존이름"); folder.setArchive(archive); ReflectionTestUtils.setField(folder, "id", 700); - when(folderRepository.findById(700)).thenReturn(Optional.of(folder)); + when(folderRepository.findByIdAndMemberId(700, 1)).thenReturn(Optional.of(folder)); when(folderRepository.findNamesForConflictCheck(archive.getId(), "보고서", "기존이름")) .thenReturn(List.of("보고서")); + // when & then assertThrows(IllegalArgumentException.class, - () -> folderService.updateFolderName(700, "보고서")); + () -> folderService.updateFolderName(1, 700, "보고서")); verify(folderRepository, never()).save(any(Folder.class)); } @@ -232,7 +261,8 @@ void getFilesInFolderForPersonal_success() { folder.setName("docs"); folder.setArchive(archive); ReflectionTestUtils.setField(folder, "id", folderId); - when(folderRepository.findById(folderId)).thenReturn(Optional.of(folder)); + + when(folderRepository.findByIdAndMemberId(folderId, 1)).thenReturn(Optional.of(folder)); DataSource d1 = new DataSource(); ReflectionTestUtils.setField(d1, "id", 10); @@ -242,6 +272,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); DataSource d2 = new DataSource(); ReflectionTestUtils.setField(d2, "id", 11); @@ -251,6 +282,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); when(dataSourceRepository.findAllByFolder(folder)).thenReturn(List.of(d1, d2)); @@ -273,14 +305,11 @@ void getFilesInFolderForPersonal_success() { void getFilesInFolderForPersonal_notFound() { // given Integer folderId = 999; - when(folderRepository.findById(folderId)).thenReturn(Optional.empty()); + when(folderRepository.findByIdAndMemberId(folderId, 1)).thenReturn(Optional.empty()); // when & then assertThrows(NoResultException.class, () -> folderService.getFilesInFolderForPersonal(1, folderId)); - - // then(verify) verify(dataSourceRepository, never()).findAllByFolder(any()); } - } 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 3fc3bc2f..2643b8c0 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 @@ -1,91 +1,179 @@ package org.tuna.zoopzoop.backend.domain.datasource.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.NoResultException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.junit.jupiter.api.*; +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.http.MediaType; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +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.service.FolderService; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; import org.tuna.zoopzoop.backend.domain.datasource.dto.*; -import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; -import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; - +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.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; + +import java.time.LocalDate; import java.util.List; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.hamcrest.Matchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class DatasourceControllerTest { - @Mock private DataSourceService dataSourceService; - @InjectMocks private DatasourceController datasourceController; - - private MockMvc mockMvc; - private ObjectMapper om; - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - om = new ObjectMapper(); + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @Autowired private MemberService memberService; + @Autowired private MemberRepository memberRepository; + @Autowired private FolderService folderService; + @Autowired private FolderRepository folderRepository; + @Autowired private DataSourceRepository dataSourceRepository; + + private final String TEST_PROVIDER_KEY = "testUser_sc1111"; // WithUserDetails username -> "KAKAO:testUser_sc1111" + + private Integer testMemberId; + private Integer docsFolderId; + private Integer dataSourceId1; + private Integer dataSourceId2; + + @BeforeAll + void beforeAll() { + try { + memberService.createMember("testUser_sc1111", "http://img", TEST_PROVIDER_KEY, Provider.KAKAO); + } catch (Exception ignored) {} + + var member = memberRepository.findByProviderAndProviderKey(Provider.KAKAO, TEST_PROVIDER_KEY) + .orElseThrow(); + testMemberId = member.getId(); + + // docs 폴더 생성 + FolderResponse fr = folderService.createFolderForPersonal(testMemberId, "docs"); + docsFolderId = fr.folderId(); + + Folder docsFolder = folderRepository.findById(docsFolderId).orElseThrow(); + + Integer archiveId = docsFolder.getArchive().getId(); + folderRepository.findByArchiveIdAndIsDefaultTrue(archiveId) + .orElseGet(() -> { + Folder defaultFolder = new Folder(); + defaultFolder.setArchive(docsFolder.getArchive()); + defaultFolder.setName("default"); + defaultFolder.setDefault(true); + return folderRepository.save(defaultFolder); + }); + + // 자료 2건 생성 + DataSource d1 = new DataSource(); + d1.setFolder(docsFolder); + d1.setTitle("spec.pdf"); + d1.setSummary("요약 A"); + d1.setSourceUrl("http://src/a"); + d1.setImageUrl("http://img/a"); + d1.setDataCreatedDate(LocalDate.now()); + d1.setActive(true); + d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); + d1.setCategory(Category.IT); + dataSourceRepository.save(d1); + dataSourceId1 = d1.getId(); + + DataSource d2 = new DataSource(); + d2.setFolder(docsFolder); + d2.setTitle("notes.txt"); + d2.setSummary("요약 B"); + d2.setSourceUrl("http://src/b"); + d2.setImageUrl("http://img/b"); + d2.setDataCreatedDate(LocalDate.now()); + d2.setActive(true); + d2.setTags(List.of()); + d2.setCategory(Category.SCIENCE); + dataSourceRepository.save(d2); + dataSourceId2 = d2.getId(); + } - // ✅ 한 번만 생성 + 전역 예외핸들러 등록 - mockMvc = MockMvcBuilders - .standaloneSetup(datasourceController) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); + @AfterAll + void afterAll() { + // 생성한 자료/폴더/멤버 삭제 + try { + if (dataSourceId1 != null) dataSourceRepository.findById(dataSourceId1).ifPresent(dataSourceRepository::delete); + } catch (Exception ignored) {} + try { + if (dataSourceId2 != null) dataSourceRepository.findById(dataSourceId2).ifPresent(dataSourceRepository::delete); + } catch (Exception ignored) {} + + try { + if (docsFolderId != null) folderRepository.findById(docsFolderId).ifPresent(folderRepository::delete); + } catch (Exception ignored) {} + + memberRepository.findByProviderAndProviderKey(Provider.KAKAO, TEST_PROVIDER_KEY) + .ifPresent(memberRepository::delete); } // create @Test @DisplayName("자료 생성 성공 - folderId=null → default 폴더에 등록") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void create_defaultFolder() throws Exception { var rq = new reqBodyForCreateDataSource("https://example.com/a", null); - when(dataSourceService.createDataSource(anyInt(), eq(rq.sourceUrl()), isNull())) - .thenReturn(1001); - mockMvc.perform(post("/api/v1/archive") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(rq))) + .content(objectMapper.writeValueAsString(rq))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.msg").value("새로운 자료가 등록됐습니다.")) - .andExpect(jsonPath("$.data").value(1001)); + .andExpect(jsonPath("$.data").isNumber()); } @Test @DisplayName("자료 생성 성공 - folderId 지정 → 해당 폴더에 등록") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void create_specificFolder() throws Exception { - var rq = new reqBodyForCreateDataSource("https://example.com/b", 55); - - when(dataSourceService.createDataSource(anyInt(), eq(rq.sourceUrl()), eq(rq.folderId()))) - .thenReturn(2002); + var rq = new reqBodyForCreateDataSource("https://example.com/b", docsFolderId); mockMvc.perform(post("/api/v1/archive") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(rq))) + .content(objectMapper.writeValueAsString(rq))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.msg").value("새로운 자료가 등록됐습니다.")) - .andExpect(jsonPath("$.data").value(2002)); + .andExpect(jsonPath("$.data").isNumber()); } // delete @Test @DisplayName("단건 삭제 성공 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void delete_success() throws Exception { - // given - int id = 123; - when(dataSourceService.deleteById(id)).thenReturn(id); + DataSource d = new DataSource(); + d.setFolder(folderRepository.findById(docsFolderId).orElseThrow()); + d.setTitle("tmp_delete"); + d.setSummary("tmp"); + d.setSourceUrl("http://s"); + d.setImageUrl("http://i"); + d.setDataCreatedDate(LocalDate.now()); + d.setActive(true); + d.setCategory(Category.IT); + dataSourceRepository.save(d); + Integer id = d.getId(); - // when & then mockMvc.perform(delete("/api/v1/archive/{id}", id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) @@ -95,12 +183,9 @@ void delete_success() throws Exception { @Test @DisplayName("단건 삭제 실패: 자료 없음 → 404 Not Found") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void delete_notFound() throws Exception { - int id = 999; - when(dataSourceService.deleteById(id)) - .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); - - mockMvc.perform(delete("/api/v1/archive/{id}", id)) + mockMvc.perform(delete("/api/v1/archive/{id}", 999999)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value("404")) .andExpect(jsonPath("$.msg").value("존재하지 않는 자료입니다.")); @@ -109,129 +194,133 @@ void delete_notFound() throws Exception { // deleteMany @Test @DisplayName("다건 삭제 성공 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void deleteMany_success() throws Exception { - var body = new reqBodyForDeleteMany(List.of(10, 20, 30)); - doNothing().when(dataSourceService).deleteMany(anyList()); + DataSource a = new DataSource(); a.setFolder(folderRepository.findById(docsFolderId).orElseThrow()); + a.setTitle("tmp_a"); a.setSummary("a"); a.setSourceUrl("a"); a.setImageUrl("a"); a.setDataCreatedDate(LocalDate.now()); a.setActive(true); a.setCategory(Category.IT); + DataSource b = new DataSource(); b.setFolder(folderRepository.findById(docsFolderId).orElseThrow()); + b.setTitle("tmp_b"); b.setSummary("b"); b.setSourceUrl("b"); b.setImageUrl("b"); b.setDataCreatedDate(LocalDate.now()); b.setActive(true); b.setCategory(Category.IT); + dataSourceRepository.save(a); dataSourceRepository.save(b); + + var body = new reqBodyForDeleteMany(List.of(a.getId(), b.getId())); mockMvc.perform(post("/api/v1/archive/delete") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.msg").value("복수개의 자료가 삭제됐습니다.")) - .andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue())); + .andExpect(jsonPath("$.data").value(nullValue())); } @Test @DisplayName("다건 삭제 실패: 배열 비어있음 → 400 Bad Request") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void deleteMany_empty() throws Exception { - // @NotEmpty로 잡히면 MethodArgumentNotValidException(400), 서비스에서 잡히면 IllegalArgumentException(400) var empty = new reqBodyForDeleteMany(List.of()); mockMvc.perform(post("/api/v1/archive/delete") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(empty))) + .content(objectMapper.writeValueAsString(empty))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value("400")); } @Test @DisplayName("다건 삭제 실패: 일부 ID 미존재 → 404 Not Found") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void deleteMany_partialMissing() throws Exception { - var body = new reqBodyForDeleteMany(List.of(1, 2, 3)); - doThrow(new NoResultException("존재하지 않는 자료 ID 포함: [2]")) - .when(dataSourceService).deleteMany(anyList()); + var body = new reqBodyForDeleteMany(List.of(dataSourceId1, 999999)); mockMvc.perform(post("/api/v1/archive/delete") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value("404")) - .andExpect(jsonPath("$.msg").value("존재하지 않는 자료 ID 포함: [2]")); + .andExpect(jsonPath("$.status").value("404")); } // 자료 단건 이동 @Test @DisplayName("단건 이동 성공 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveOne_ok() throws Exception { - // given - when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) - .thenReturn(new DataSourceService.MoveResult(1, 200)); + FolderResponse newFolder = folderService.createFolderForPersonal(testMemberId, "moveTarget"); + Integer toId = newFolder.folderId(); - String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + var body = new reqBodyForMoveDataSource(toId); - // expect - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.dataSourceId").value(1)) - .andExpect(jsonPath("$.data.folderId").value(200)); + .andExpect(jsonPath("$.data.dataSourceId").value(dataSourceId1)) + .andExpect(jsonPath("$.data.folderId").value(toId)); } @Test - @DisplayName(" 단건 이동 성공: default 폴더(null) -> 200") + @DisplayName("단건 이동 성공: default 폴더(null) -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveOne_default_ok() throws Exception { - when(dataSourceService.moveDataSource(anyInt(), eq(1), isNull())) - .thenReturn(new DataSourceService.MoveResult(1, 999)); // default folder id - - String body = om.writeValueAsString(new reqBodyForMoveDataSource(null)); + var body = new reqBodyForMoveDataSource(null); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.folderId").value(999)); + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.dataSourceId").value(dataSourceId1)) + .andExpect(jsonPath("$.data.folderId").isNumber()); } @Test - @DisplayName("단건 이동 실패: 자료 없음 -> 400") + @DisplayName("단건 이동 실패: 자료 없음 -> 404") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveOne_notFound_data() throws Exception { - when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) - .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); - - String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + var body = new reqBodyForMoveDataSource(docsFolderId); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 999999) .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()); } @Test @DisplayName("단건 이동 실패: 폴더 없음 -> 404") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveOne_notFound_folder() throws Exception { - when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) - .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); - - String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + // 임의의 존재하지 않는 폴더로 이동 시도 + var body = new reqBodyForMoveDataSource(999999); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()); } - // 자료 다건 이동 + // 자료 다건 이동 (지정 폴더) @Test - @DisplayName("다건 이동 성공: 지정 폴더 -> 200") + @DisplayName("자료 다건 이동 성공: 지정 폴더 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveMany_specific_ok() throws Exception { - String body = "{\"folderId\":200,\"dataSourceId\":[1,2,3]}"; + FolderResponse newFolder = folderService.createFolderForPersonal(testMemberId, "moveManyTarget"); + Integer toId = newFolder.folderId(); + + String body = String.format("{\"folderId\":%d,\"dataSourceId\":[%d,%d]}", toId, dataSourceId1, dataSourceId2); mockMvc.perform(patch("/api/v1/archive/move") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value("복수 개의 자료를 이동했습니다.")) - .andExpect(jsonPath("$.data").doesNotExist()); + .andExpect(jsonPath("$.msg").value("복수 개의 자료를 이동했습니다.")); } + @Test - @DisplayName("다건 이동 성공: 기본 폴더(null) -> 200") + @DisplayName("자료 다건 이동 성공: 기본 폴더(null) -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveMany_default_ok() throws Exception { - // 서비스는 void 리턴이라 스텁 불필요 (예외만 없으면 200) - String body = "{\"folderId\":null,\"dataSourceId\":[1,2,3]}"; + String body = String.format("{\"folderId\":null,\"dataSourceId\":[%d,%d]}", dataSourceId1, dataSourceId2); mockMvc.perform(patch("/api/v1/archive/move") .contentType(MediaType.APPLICATION_JSON) @@ -241,46 +330,31 @@ void moveMany_default_ok() throws Exception { .andExpect(jsonPath("$.msg").value("복수 개의 자료를 이동했습니다.")); } - @Test - @DisplayName("다건 이동 실패: 기본 폴더 없음 -> 404") - void moveMany_default_missing() throws Exception { - String body = "{\"folderId\":null,\"dataSourceId\":[1,2]}"; - - doThrow(new NoResultException("기본 폴더가 존재하지 않습니다.")) - .when(dataSourceService).moveDataSources(anyInt(), isNull(), eq(List.of(1,2))); - - mockMvc.perform(patch("/api/v1/archive/move") - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isNotFound()); - } - // 자료 수정 @Test @DisplayName("자료 수정 성공 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void update_ok() throws Exception { - int id = 10; - when(dataSourceService.updateDataSource(eq(id), eq("새 제목"), eq("짧은 요약"))) - .thenReturn(id); - var body = new reqBodyForUpdateDataSource("새 제목", "짧은 요약"); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", id) + + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value(id + "번 자료가 수정됐습니다.")) - .andExpect(jsonPath("$.data.dataSourceId").value(id)); + .andExpect(jsonPath("$.msg").exists()) + .andExpect(jsonPath("$.data.dataSourceId").value(dataSourceId1)); } @Test @DisplayName("자료 수정 실패: 요청 바디가 모두 공백 -> 400") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void update_badRequest_whenEmpty() throws Exception { var body = new reqBodyForUpdateDataSource(" ", null); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.msg").exists()); @@ -288,20 +362,14 @@ void update_badRequest_whenEmpty() throws Exception { @Test @DisplayName("자료 수정 실패: 존재하지 않는 자료 -> 404") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void update_notFound() throws Exception { - int id = 999; - when(dataSourceService.updateDataSource(eq(id), any(), any())) - .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); - var body = new reqBodyForUpdateDataSource("제목", "요약"); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", id) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", 999999) .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)) - .andExpect(jsonPath("$.msg").value("존재하지 않는 자료입니다.")); + .andExpect(jsonPath("$.status").value(404)); } - - } 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 f19f9d27..2660cb89 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 @@ -46,13 +46,11 @@ void createDataSource_defaultFolder() { // PersonalArchive 생성 시 Archive + default folder 자동 생성됨 Member member = new Member("u1", "k-1", Provider.KAKAO, null); PersonalArchive pa = new PersonalArchive(member); - Integer archiveId = pa.getArchive().getId(); // 실제 id는 없지만, 아래 anyInt()로 받게 스텁함 when(personalArchiveRepository.findByMemberId(eq(currentMemberId))) .thenReturn(Optional.of(pa)); Folder defaultFolder = new Folder("default"); - // 리얼 구현은 archiveId 기준으로 찾으니 시그니처 맞추기 when(folderRepository.findByArchiveIdAndIsDefaultTrue(anyInt())) .thenReturn(Optional.of(defaultFolder)); @@ -67,7 +65,6 @@ void createDataSource_defaultFolder() { assertThat(id).isEqualTo(123); } - @Test @DisplayName("폴더 생성 성공- folderId가 주어지면 해당 폴더에 자료 생성") void createDataSource_specificFolder() { @@ -77,16 +74,14 @@ void createDataSource_specificFolder() { Integer folderId = 77; Folder target = new Folder("target"); - // BaseEntity.id 는 protected setter → 리플렉션으로 주입 - org.springframework.test.util.ReflectionTestUtils.setField(target, "id", folderId); + ReflectionTestUtils.setField(target, "id", folderId); when(folderRepository.findById(eq(folderId))).thenReturn(Optional.of(target)); - // save(...) 시에 PK가 채워진 것처럼 반환 when(dataSourceRepository.save(any(DataSource.class))) .thenAnswer(inv -> { DataSource ds = inv.getArgument(0); - org.springframework.test.util.ReflectionTestUtils.setField(ds, "id", 456); + ReflectionTestUtils.setField(ds, "id", 456); return ds; }); @@ -98,11 +93,10 @@ void createDataSource_specificFolder() { } @Test - @DisplayName("폴대 생성 실패 - folderId가 주어졌는데 대상 폴더가 없으면 예외") + @DisplayName("폴더 생성 실패 - folderId가 주어졌는데 대상 폴더가 없으면 예외") void createDataSource_folderNotFound() { // given Integer folderId = 999; - when(folderRepository.findById(eq(folderId))).thenReturn(Optional.empty()); // when / then @@ -116,7 +110,6 @@ void createDataSource_folderNotFound() { void createDataSource_defaultFolderNotFound() { // given int currentMemberId = 10; - PersonalArchive pa = new PersonalArchive(new Member("u1","p", Provider.KAKAO,null)); when(personalArchiveRepository.findByMemberId(eq(currentMemberId))) .thenReturn(Optional.of(pa)); @@ -131,15 +124,17 @@ void createDataSource_defaultFolderNotFound() { // delete @Test - @DisplayName("단건 삭제 성공 - 존재하는 자료 삭제 시 ID 반환") + @DisplayName("단건 삭제 성공 - 존재하는 자료 삭제 시 ID 반환 (member 소유 확인)") void deleteById_success() { // given + int memberId = 5; int id = 123; DataSource mockData = new DataSource(); - when(dataSourceRepository.findById(id)).thenReturn(Optional.of(mockData)); // when - int deletedId = dataSourceService.deleteById(id); + when(dataSourceRepository.findByIdAndMemberId(id, memberId)).thenReturn(Optional.of(mockData)); + + int deletedId = dataSourceService.deleteById(memberId, id); // then assertThat(deletedId).isEqualTo(id); @@ -150,11 +145,12 @@ void deleteById_success() { @DisplayName("단건 삭제 실패 - 자료가 존재하지 않으면 예외 발생") void deleteById_notFound() { // given + int memberId = 5; int id = 999; - when(dataSourceRepository.findById(id)).thenReturn(Optional.empty()); + when(dataSourceRepository.findByIdAndMemberId(id, memberId)).thenReturn(Optional.empty()); // when & then - assertThrows(NoResultException.class, () -> dataSourceService.deleteById(id)); + assertThrows(NoResultException.class, () -> dataSourceService.deleteById(memberId, id)); verify(dataSourceRepository, never()).delete(any()); } @@ -162,10 +158,12 @@ void deleteById_notFound() { @Test @DisplayName("다건 삭제 성공 - 일괄 삭제") void deleteMany_success() { + Integer memberId = 2; List ids = List.of(1, 2, 3); - when(dataSourceRepository.findExistingIds(ids)).thenReturn(ids); - dataSourceService.deleteMany(ids); + when(dataSourceRepository.findExistingIdsInMember(memberId, ids)).thenReturn(ids); + + dataSourceService.deleteMany(memberId, ids); verify(dataSourceRepository).deleteAllByIdInBatch(ids); } @@ -173,17 +171,19 @@ void deleteMany_success() { @Test @DisplayName("다건 삭제 실패 - 요청 배열이 비어있음 → 400") void deleteMany_empty() { - assertThrows(IllegalArgumentException.class, () -> dataSourceService.deleteMany(List.of())); + Integer memberId = 2; + assertThrows(IllegalArgumentException.class, () -> dataSourceService.deleteMany(memberId, List.of())); verifyNoInteractions(dataSourceRepository); } @Test @DisplayName("다건 삭제 실패 - 일부 ID 미존재 → 404") void deleteMany_partialMissing() { + Integer memberId = 2; List ids = List.of(1, 2, 3); - when(dataSourceRepository.findExistingIds(ids)).thenReturn(List.of(1, 3)); + when(dataSourceRepository.findExistingIdsInMember(memberId, ids)).thenReturn(List.of(1, 3)); - assertThrows(NoResultException.class, () -> dataSourceService.deleteMany(ids)); + assertThrows(NoResultException.class, () -> dataSourceService.deleteMany(memberId, ids)); verify(dataSourceRepository, never()).deleteAllByIdInBatch(any()); } @@ -202,7 +202,7 @@ void moveOne_ok() { ReflectionTestUtils.setField(ds, "id", dsId); ds.setTitle("A"); ds.setFolder(from); - when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(dataSourceRepository.findByIdAndMemberId(dsId, memberId)).thenReturn(Optional.of(ds)); when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); DataSourceService.MoveResult rs = dataSourceService.moveDataSource(memberId, dsId, toId); @@ -213,7 +213,7 @@ void moveOne_ok() { } @Test - @DisplayName("단건이동 성공: 기본 폴더(null)로 이동") + @DisplayName("단건 이동 성공: 기본 폴더(null) -> 200") void moveOne_default_ok() { Integer memberId = 7, dsId = 1, fromId = 100, defaultId = 999; @@ -224,7 +224,7 @@ void moveOne_default_ok() { ReflectionTestUtils.setField(ds, "id", dsId); ds.setTitle("문서A"); ds.setFolder(from); - when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(dataSourceRepository.findByIdAndMemberId(dsId, memberId)).thenReturn(Optional.of(ds)); when(folderRepository.findDefaultFolderByMemberId(memberId)) .thenReturn(Optional.of(defaultFolder)); @@ -246,7 +246,7 @@ void moveOne_idempotent() { ReflectionTestUtils.setField(ds, "id", dsId); ds.setTitle("A"); ds.setFolder(same); - when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(dataSourceRepository.findByIdAndMemberId(dsId, memberId)).thenReturn(Optional.of(ds)); when(folderRepository.findById(folderId)).thenReturn(Optional.of(same)); DataSourceService.MoveResult rs = dataSourceService.moveDataSource(memberId, dsId, folderId); @@ -256,11 +256,12 @@ void moveOne_idempotent() { } @Test - @DisplayName("단건 이동 실패: 자료 없음 → NoResultException") + @DisplayName("단건 이동 실패: 자료 없음 → NoResultException (소유자 검증)") void moveOne_notFound_data() { - when(dataSourceRepository.findById(1)).thenReturn(Optional.empty()); + Integer memberId = 1, dsId = 1; + when(dataSourceRepository.findByIdAndMemberId(dsId, memberId)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> dataSourceService.moveDataSource(1, 1, 200)) + assertThatThrownBy(() -> dataSourceService.moveDataSource(memberId, dsId, 200)) .isInstanceOf(NoResultException.class) .hasMessageContaining("존재하지 않는 자료"); } @@ -268,23 +269,46 @@ void moveOne_notFound_data() { @Test @DisplayName("단건 이동 실패: 폴더 없음 → NoResultException") void moveOne_notFound_folder() { + Integer memberId = 1; Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); DataSource ds = new DataSource(); ReflectionTestUtils.setField(ds, "id", 1); ds.setTitle("A"); ds.setFolder(from); - when(dataSourceRepository.findById(1)).thenReturn(Optional.of(ds)); + when(dataSourceRepository.findByIdAndMemberId(1, memberId)).thenReturn(Optional.of(ds)); when(folderRepository.findById(200)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> dataSourceService.moveDataSource(1, 1, 200)) + assertThatThrownBy(() -> dataSourceService.moveDataSource(memberId, 1, 200)) .isInstanceOf(NoResultException.class) .hasMessageContaining("존재하지 않는 폴더"); } // 자료 다건 이동 @Test - @DisplayName("다건: folderId=null → 기본 폴더로 이동") + @DisplayName("다건 이동 성공: 지정 폴더로 이동") + void moveMany_ok() { + Integer memberId = 1; + Integer toId = 200; + Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); + Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); + + DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(from); + DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(from); + + when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); + // 소유자 검증: member 소유인 id들 반환 + when(dataSourceRepository.findExistingIdsInMember(memberId, List.of(1,2))).thenReturn(List.of(1,2)); + when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); + + dataSourceService.moveDataSources(memberId, toId, List.of(1,2)); + + assertThat(a.getFolder().getId()).isEqualTo(toId); + assertThat(b.getFolder().getId()).isEqualTo(toId); + } + + @Test + @DisplayName("다건 이동 성공: folderId=null → 기본 폴더로 이동") void moveMany_default_ok() { Integer memberId = 7, defaultId = 999; @@ -295,6 +319,7 @@ void moveMany_default_ok() { DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(from); when(folderRepository.findDefaultFolderByMemberId(memberId)).thenReturn(Optional.of(defaultFolder)); + when(dataSourceRepository.findExistingIdsInMember(memberId, List.of(1,2))).thenReturn(List.of(1,2)); when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); dataSourceService.moveDataSources(memberId, null, List.of(1,2)); @@ -305,67 +330,31 @@ void moveMany_default_ok() { } @Test - @DisplayName("다건: folderId=null & 기본 폴더 없음 → NoResultException") + @DisplayName("다건 이동 실패: folderId=null & 기본 폴더 없음 → NoResultException") void moveMany_default_missing() { when(folderRepository.findDefaultFolderByMemberId(7)).thenReturn(Optional.empty()); - + // 멤버 소유 검증 전이라도 default 조회에서 예외 발생 assertThatThrownBy(() -> dataSourceService.moveDataSources(7, null, List.of(1))) .isInstanceOf(NoResultException.class) .hasMessageContaining("기본 폴더"); } @Test - @DisplayName("다건: 지정 폴더로 이동") - void moveMany_ok() { - Integer toId = 200; - Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); - Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); - - DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(from); - DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(from); - - when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); - when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); - - dataSourceService.moveDataSources(1, toId, List.of(1,2)); - - assertThat(a.getFolder().getId()).isEqualTo(toId); - assertThat(b.getFolder().getId()).isEqualTo(toId); - } - - @Test - @DisplayName("다건: 모두 동일 폴더 → 멱등") - void moveMany_idempotent() { - Integer toId = 200; - Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); - - DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(to); - DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(to); - - when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); - when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); - - dataSourceService.moveDataSources(1, toId, List.of(1,2)); - - verify(folderRepository).findById(toId); - verify(dataSourceRepository).findAllByIdIn(List.of(1,2)); - verifyNoMoreInteractions(folderRepository, dataSourceRepository); - } - - @Test - @DisplayName("다건: 일부 미존재 → NoResultException") + @DisplayName("다건 이동 실패: 일부 미존재 → NoResultException (소유자 검증 실패)") void moveMany_someNotFound() { + Integer memberId = 1; Integer toId = 200; Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(new Folder()); when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); - when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a)); // 2 없음 + // 소유자 검증에서 일부만 리턴 + when(dataSourceRepository.findExistingIdsInMember(memberId, List.of(1,2))).thenReturn(List.of(1)); - assertThatThrownBy(() -> dataSourceService.moveDataSources(1, toId, List.of(1,2))) + assertThatThrownBy(() -> dataSourceService.moveDataSources(memberId, toId, List.of(1,2))) .isInstanceOf(NoResultException.class) - .hasMessageContaining("존재하지 않는 항목"); + .hasMessageContaining("존재하지 않거나 소유자가 다른 자료 ID 포함"); } @Test @@ -381,7 +370,7 @@ void moveMany_notFound_folder() { @Test @DisplayName("다건: 요소 null → IllegalArgumentException") void moveMany_elementNull() { - List ids = Arrays.asList(1, null, 3); // ← null 허용 + List ids = Arrays.asList(1, null, 3); assertThatThrownBy(() -> dataSourceService.moveDataSources(1, 200, ids)) .isInstanceOf(IllegalArgumentException.class) @@ -391,25 +380,21 @@ void moveMany_elementNull() { @Test @DisplayName("다건: 요청에 중복된 자료 ID 포함 → IllegalArgumentException") void moveMany_duplicatedIds_illegalArgument() { - // given List ids = List.of(1, 2, 2, 3); // 2가 중복 - // when & then assertThatThrownBy(() -> dataSourceService.moveDataSources(7, 200, ids)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("같은 자료를 두 번 선택했습니다") .hasMessageContaining("2"); - // 리포지토리 호출 전 단계에서 막혀야 함 verifyNoInteractions(folderRepository, dataSourceRepository); } @Test @DisplayName("다건: folderId=null + 중복된 자료 ID 포함 → IllegalArgumentException (default 조회 전 차단)") void moveMany_default_withDuplicatedIds_illegalArgument() { - // given List ids = List.of(5, 5); // 중복 - // when & then + assertThatThrownBy(() -> dataSourceService.moveDataSources(7, null, ids)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("같은 자료를 두 번 선택했습니다") @@ -422,15 +407,16 @@ void moveMany_default_withDuplicatedIds_illegalArgument() { @Test @DisplayName("수정 성공: 제목과 요약 일부/전체 변경") void update_ok() { + Integer memberId = 3; DataSource ds = new DataSource(); ReflectionTestUtils.setField(ds, "id", 7); ds.setTitle("old"); ds.setSummary("old sum"); - when(dataSourceRepository.findById(anyInt())) + when(dataSourceRepository.findByIdAndMemberId(eq(7), eq(memberId))) .thenReturn(Optional.of(ds)); - Integer id = dataSourceService.updateDataSource(7, "new", null); + Integer id = dataSourceService.updateDataSource(memberId, 7, "new", null); assertThat(id).isEqualTo(7); assertThat(ds.getTitle()).isEqualTo("new"); @@ -440,10 +426,11 @@ void update_ok() { @Test @DisplayName("수정 실패: 존재하지 않는 자료") void update_notFound() { - when(dataSourceRepository.findById(anyInt())) + Integer memberId = 3; + when(dataSourceRepository.findByIdAndMemberId(anyInt(), eq(memberId))) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> dataSourceService.updateDataSource(1, "t", "s")) + assertThatThrownBy(() -> dataSourceService.updateDataSource(memberId, 1, "t", "s")) .isInstanceOf(NoResultException.class) .hasMessageContaining("존재하지 않는 자료"); } From cdb8ee28e196e486ead7e9374e39e4add2f3af9f Mon Sep 17 00:00:00 2001 From: "DESKTOP-N5KD4EV\\litte" Date: Fri, 26 Sep 2025 15:59:37 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor/OPS-319=20:=20=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/datasource/service/DataSourceService.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 77da2834..3e6ba728 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 @@ -46,7 +46,7 @@ private DataSource buildDataSource(String sourceUrl, Folder folder) { ds.setFolder(folder); ds.setSourceUrl(sourceUrl); ds.setTitle("자료 제목"); - ds.setSources("www.examplesource.com"); + ds.setSource("www.examplesource.com"); ds.setSummary("설명"); ds.setImageUrl("www.example.com/img"); ds.setDataCreatedDate(LocalDate.now()); @@ -66,7 +66,7 @@ private Folder findDefaultFolder(int currentMemberId) { } /** - * 자료 단건 삭제 (소유자 검증 포함) // CHANGED + * 자료 단건 삭제 */ @Transactional public int deleteById(Integer memberId, Integer dataSourceId) { @@ -79,7 +79,7 @@ public int deleteById(Integer memberId, Integer dataSourceId) { } /** - * 자료 다건 삭제 (모든 id가 해당 멤버 소유여야 함) // CHANGED + * 자료 다건 삭제 */ @Transactional public void deleteMany(Integer memberId, List ids) { @@ -99,12 +99,11 @@ public void deleteMany(Integer memberId, List ids) { } /** - * 자료 위치 단건 이동 (현재 로직은 동일하되 이미 소유 확인이 필요한 경우 검증 추가 가능) + * 자료 위치 단건 이동 */ @Transactional public MoveResult moveDataSource(Integer currentMemberId, Integer dataSourceId, Integer targetFolderId) { - // 자료 확인: 먼저 멤버 소유인지 확인 (안하면 타인 자료 이동 위험) DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, currentMemberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); @@ -168,7 +167,7 @@ private Folder resolveTargetFolder(Integer currentMemberId, Integer targetFolder } /** - * 자료 수정 (소유자 검증 포함) // CHANGED + * 자료 수정 */ public Integer updateDataSource(Integer memberId, Integer dataSourceId, String newTitle, String newSummary) { DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) From 320f2a09b1be52f1b72bf4249dab05fd399e7c7d Mon Sep 17 00:00:00 2001 From: "DESKTOP-N5KD4EV\\litte" Date: Fri, 26 Sep 2025 17:19:23 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor/OPS-327=20:=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20LLM=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datasource/repository/TagRepository.java | 10 + .../datasource/service/DataSourceService.java | 56 +++++- .../folder/service/FolderServiceTest.java | 5 +- .../controller/DatasourceControllerTest.java | 46 ++++- .../service/DataSourceServiceTest.java | 188 +++++++++++++++++- 5 files changed, 288 insertions(+), 17 deletions(-) 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 소유 확인)")