diff --git a/build.gradle b/build.gradle index 1c8bd883..73d75248 100644 --- a/build.gradle +++ b/build.gradle @@ -82,6 +82,9 @@ dependencies { // OpenAPI / Swagger UI implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + // OpenAPI - nullable support + implementation "org.openapitools:jackson-databind-nullable:0.2.6" + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' 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 2abf6bd6..f24466fe 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 @@ -178,29 +178,48 @@ public ResponseEntity moveMany( /** * 파일 수정 - * @param dataSourceId 수정할 파일 Id - * @param body 수정할 내용 + * - 전달된 필드만 반영 (present) + * - 명시적 null이면 DB에 null 저장 + * - 미전달(not present)이면 변경 없음 */ @Operation(summary = "자료 수정", description = "내 PersonalArchive 안에 자료를 수정합니다.") @PatchMapping("/{dataSourceId}") public ResponseEntity updateDataSource( @PathVariable Integer dataSourceId, - @Valid @RequestBody reqBodyForUpdateDataSource body, + @RequestBody reqBodyForUpdateDataSource body, @AuthenticationPrincipal CustomUserDetails userDetails ) { - // title, summary 둘 다 비어있으면 의미 없는 요청 → 400 - boolean noTitle = (body.title() == null || body.title().isBlank()); - boolean noSummary = (body.summary() == null || body.summary().isBlank()); - if (noTitle && noSummary) { - throw new IllegalArgumentException("변경할 값이 없습니다. title 또는 summary 중 하나 이상을 전달하세요."); + boolean anyPresent = + body.title().isPresent() || + body.summary().isPresent() || + body.sourceUrl().isPresent() || + body.imageUrl().isPresent() || + body.source().isPresent() || + body.tags().isPresent() || + body.category().isPresent(); + + if (!anyPresent) { + throw new IllegalArgumentException( + "변경할 값이 없습니다. title, summary, sourceUrl, imageUrl, source, tags, category 중 하나 이상을 전달하세요." + ); } - 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)) + Integer updatedId = dataSourceService.updateDataSource( + userDetails.getMember().getId(), + dataSourceId, + DataSourceService.UpdateCommand.builder() + .title(body.title()) + .summary(body.summary()) + .sourceUrl(body.sourceUrl()) + .imageUrl(body.imageUrl()) + .source(body.source()) + .tags(body.tags()) + .category(body.category()) + .build() ); + + 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/reqBodyForUpdateDataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java index 9a848ba5..56a54e91 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java @@ -1,8 +1,13 @@ package org.tuna.zoopzoop.backend.domain.datasource.dto; -import jakarta.validation.constraints.NotNull; +import org.openapitools.jackson.nullable.JsonNullable; public record reqBodyForUpdateDataSource( - @NotNull String title, - @NotNull String summary + JsonNullable title, + JsonNullable summary, + JsonNullable sourceUrl, + JsonNullable imageUrl, + JsonNullable source, + JsonNullable> tags, + JsonNullable category ) {} 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 fe96b14e..6f617e47 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 @@ -15,17 +15,6 @@ @Setter @Entity @NoArgsConstructor -@Table( - uniqueConstraints = { - // 복합 Unique 제약(folder_id, title) - // 같은 폴더 내에 자료 제목 중복 금지 - @UniqueConstraint(columnNames = {"folder_id", "title"}) - }, - // 폴더 내 자료 목록 조회 최적화 - indexes = { - @Index(name = "idx_datasource__folder_id", columnList = "folder_id") - } -) public class DataSource extends BaseEntity { //연결된 폴더 id @ManyToOne(fetch = FetchType.LAZY, optional = false) @@ -33,20 +22,20 @@ public class DataSource extends BaseEntity { private Folder folder; //제목 - @Column(nullable = false) + @Column private String title; //요약 - @Column(nullable = false) + @Column private String summary; //소스 데이터의 작성일자 //DB 저장용 createdDate와 다름. - @Column(nullable = false) + @Column private LocalDate dataCreatedDate; //소스 데이터 URL - @Column(nullable = false) + @Column private String sourceUrl; //썸네일 이미지 URL @@ -54,6 +43,7 @@ public class DataSource extends BaseEntity { private String imageUrl; // 자료 출처 (동아일보, Tstory 등등) + @Column private String source; // 태그 목록 @@ -62,11 +52,11 @@ public class DataSource extends BaseEntity { // 카테고리 목록 @Enumerated(EnumType.STRING) // IT, SCIENCE 등 ENUM 이름으로 저장 - @Column(nullable = false) + @Column private Category category; // 활성화 여부 - @Column(nullable = false) + @Column private boolean isActive = true; // 삭제 일자 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 87f1d877..3c30ef6b 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 @@ -2,7 +2,9 @@ import jakarta.persistence.NoResultException; import jakarta.transaction.Transactional; +import lombok.Builder; import lombok.RequiredArgsConstructor; +import org.openapitools.jackson.nullable.JsonNullable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -15,6 +17,7 @@ import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchCondition; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceSearchItem; +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.DataSourceQRepository; @@ -236,19 +239,50 @@ private Folder resolveTargetFolder(Integer currentMemberId, Integer targetFolder /** * 자료 수정 */ - public Integer updateDataSource(Integer memberId, Integer dataSourceId, String newTitle, String newSummary) { + @Builder + public record UpdateCommand( + JsonNullable title, + JsonNullable summary, + JsonNullable sourceUrl, + JsonNullable imageUrl, + JsonNullable source, + JsonNullable> tags, + JsonNullable category + ) {} + + @Transactional + public Integer updateDataSource(Integer memberId, Integer dataSourceId, UpdateCommand cmd) { DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); - if (newTitle != null && !newTitle.isBlank()) - ds.setTitle(newTitle); - - if (newSummary != null && !newSummary.isBlank()) - ds.setSummary(newSummary); + // 문자열/enum 필드들 + if (cmd.title().isPresent()) ds.setTitle(cmd.title().orElse(null)); + if (cmd.summary().isPresent()) ds.setSummary(cmd.summary().orElse(null)); + if (cmd.sourceUrl().isPresent()) ds.setSourceUrl(cmd.sourceUrl().orElse(null)); + if (cmd.imageUrl().isPresent()) ds.setImageUrl(cmd.imageUrl().orElse(null)); + if (cmd.source().isPresent()) ds.setSource(cmd.source().orElse(null)); + if (cmd.category().isPresent()) ds.setCategory(parseCategoryNullable(cmd.category().orElse(null))); + + // 태그 + if (cmd.tags().isPresent()) { + List names = cmd.tags().orElse(null); + if (names == null) { + ds.getTags().clear(); + } else { + replaceTags(ds, names); + } + } return ds.getId(); } + private Category parseCategoryNullable(String raw) { + if (raw == null) return null; + String k = raw.trim(); + if (k.isEmpty()) return null; // 빈문자 들어오면 null로 저장(원하면 그대로 저장하도록 바꿔도 됨) + return Category.valueOf(k.toUpperCase(Locale.ROOT)); + } + /** * 자료 검색 */ @@ -272,4 +306,17 @@ public Page search(Integer memberId, DataSourceSearchCondi } public record MoveResult(Integer datasourceId, Integer folderId) {} + + private void replaceTags(DataSource ds, List names) { + ds.getTags().clear(); + + for (String name : names) { + if (name == null) continue; + Tag tag = Tag.builder() + .tagName(name) + .dataSource(ds) + .build(); + ds.getTags().add(tag); + } + } } 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 fd01bf18..9097875a 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 @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.mockito.Mockito; +import org.openapitools.jackson.nullable.JsonNullableModule; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -17,10 +18,13 @@ 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.archive.folder.service.FolderService; 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.dto.DataSourceDto; +import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForCreateDataSource; +import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForDeleteMany; +import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForMoveDataSource; 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; @@ -37,9 +41,8 @@ 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.*; -import static org.hamcrest.Matchers.anyOf; -import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ActiveProfiles("test") @SpringBootTest @@ -95,6 +98,11 @@ TagRepository stubTagRepository() { return mock; } + + @Bean + com.fasterxml.jackson.databind.Module jsonNullableModule() { + return new JsonNullableModule(); + } } @BeforeAll @@ -446,15 +454,31 @@ void moveMany_default_ok() throws Exception { } // 자료 수정 + + private String updateJson( + String title, String summary, String sourceUrl, + String imageUrl, String source, List tags, String category + ) throws Exception { + var map = new java.util.LinkedHashMap(); + if (title != null) map.put("title", title); + if (summary != null) map.put("summary", summary); + if (sourceUrl != null) map.put("sourceUrl", sourceUrl); + if (imageUrl != null) map.put("imageUrl", imageUrl); + if (source != null) map.put("source", source); + if (tags != null) map.put("tags", tags); + if (category != null) map.put("category", category); + return objectMapper.writeValueAsString(map); + } + @Test - @DisplayName("자료 수정 성공 -> 200") + @DisplayName("자료 수정 성공: title+summary만 부분 수정 → 200") @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) - void update_ok() throws Exception { - var body = new reqBodyForUpdateDataSource("새 제목", "짧은 요약"); + void update_ok_title_summary_only() throws Exception { + String body = updateJson("새 제목", "짧은 요약", null, null, null, null, null); mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(body))) + .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.msg").exists()) @@ -462,32 +486,56 @@ void update_ok() throws Exception { } @Test - @DisplayName("자료 수정 실패: 요청 바디가 모두 공백 -> 400") + @DisplayName("자료 수정 성공: 확장 필드 전부(대소문자 category 허용, imageUrl='', source='') → 200") @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) - void update_badRequest_whenEmpty() throws Exception { - var body = new reqBodyForUpdateDataSource(" ", null); + void update_ok_all_fields() throws Exception { + String body = updateJson( + "T2", // title + "S2", // summary + "https://new.src", // sourceUrl + "", // imageUrl + "", // source + List.of("A","B"), // tags 리스트 + "science" // category + ); mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(body))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.status").value(400)) - .andExpect(jsonPath("$.msg").exists()); + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.dataSourceId").value(dataSourceId1)); + } + + + @Test + @DisplayName("자료 수정 성공: sourceUrl가 빈문자여도 허용 → 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + void update_ok_sourceUrl_blank_allowed() throws Exception { + String body = updateJson(null, null, " ", null, null, null, null); + + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)); } @Test - @DisplayName("자료 수정 실패: 존재하지 않는 자료 -> 404") + @DisplayName("자료 수정 실패: 모든 필드 미전달 → 400") @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) - void update_notFound() throws Exception { - var body = new reqBodyForUpdateDataSource("제목", "요약"); + void update_badRequest_all_null() throws Exception { + // 빈 JSON 객체 {} + String body = "{}"; - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", 999999) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(body))) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)); + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); } + // 검색 @Test @DisplayName("검색 성공: page, size, dataCreatedDate DESC 기본정렬") 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 8f24327e..33033403 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 @@ -8,6 +8,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.openapitools.jackson.nullable.JsonNullable; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; @@ -241,7 +242,6 @@ void collectDistinctTagsOfFolder_success() { when(tagRepository.findDistinctTagNamesByFolderId(eq(folderId))) .thenReturn(List.of("AI", "Spring", "JPA")); - @SuppressWarnings("unchecked") List ctxTags = ReflectionTestUtils.invokeMethod( dataSourceService, "collectDistinctTagsOfFolder", folderId ); @@ -681,25 +681,162 @@ void moveMany_default_withDuplicatedIds_illegalArgument() { } // 자료 수정 - @Test - @DisplayName("수정 성공: 제목과 요약 일부/전체 변경") - void update_ok() { - Integer memberId = 3; + private DataSourceService.UpdateCommand cmd( + JsonNullable title, + JsonNullable summary, + JsonNullable sourceUrl, + JsonNullable imageUrl, + JsonNullable source, + JsonNullable> tags, + JsonNullable category + ) { + return DataSourceService.UpdateCommand.builder() + .title(title) + .summary(summary) + .sourceUrl(sourceUrl) + .imageUrl(imageUrl) + .source(source) + .tags(tags) + .category(category) + .build(); + } + + + private DataSource baseDs(Folder folder) { DataSource ds = new DataSource(); - ReflectionTestUtils.setField(ds, "id", 7); - ds.setTitle("old"); - ds.setSummary("old sum"); + ds.setFolder(folder); + ds.setTitle("old-title"); + ds.setSummary("old-summary"); + ds.setSourceUrl("http://old.src"); + ds.setImageUrl("http://old.img"); + ds.setSource("old-source"); + ds.setCategory(Category.IT); + ds.setActive(true); + ds.setTags(new java.util.ArrayList<>(List.of(new Tag("x"), new Tag("y")))); + // 태그의 dataSource 역방향 세팅 + ds.getTags().forEach(t -> t.setDataSource(ds)); + ReflectionTestUtils.setField(ds, "id", 77); + return ds; + } + + + @Test + @DisplayName("수정 성공: 값 세팅 - category 대소문자 허용, tags 전량 교체") + void update_set_values_ok() { + Integer memberId = 1; + Folder f = new Folder("f"); ReflectionTestUtils.setField(f, "id", 10); + DataSource ds = baseDs(f); + + when(dataSourceRepository.findByIdAndMemberId(eq(77), eq(memberId))) + .thenReturn(Optional.of(ds)); + + var command = cmd( + JsonNullable.of("new-title"), + JsonNullable.of("new-summary"), + JsonNullable.of("https://new.src"), + JsonNullable.of("https://new.img"), + JsonNullable.of("new-source"), + JsonNullable.of(List.of("A", "B")), + JsonNullable.of("science") + ); - when(dataSourceRepository.findByIdAndMemberId(eq(7), eq(memberId))) + Integer id = dataSourceService.updateDataSource(memberId, 77, command); + + assertThat(id).isEqualTo(77); + assertThat(ds.getTitle()).isEqualTo("new-title"); + assertThat(ds.getSummary()).isEqualTo("new-summary"); + assertThat(ds.getSourceUrl()).isEqualTo("https://new.src"); + assertThat(ds.getImageUrl()).isEqualTo("https://new.img"); + assertThat(ds.getSource()).isEqualTo("new-source"); + assertThat(ds.getCategory()).isEqualTo(Category.SCIENCE); + assertThat(ds.getTags()).extracting(Tag::getTagName).containsExactlyInAnyOrder("A","B"); + assertThat(ds.getTags().stream().allMatch(t -> t.getDataSource() == ds)).isTrue(); + } + + @Test + @DisplayName("수정 성공: present+null → 해당 필드 null 저장, tags=null → 전체 삭제") + void update_explicit_nulls_set_to_null() { + Integer memberId = 1; + Folder f = new Folder("f"); ReflectionTestUtils.setField(f, "id", 10); + DataSource ds = baseDs(f); + + when(dataSourceRepository.findByIdAndMemberId(eq(77), eq(memberId))) .thenReturn(Optional.of(ds)); - Integer id = dataSourceService.updateDataSource(memberId, 7, "new", null); + var command = cmd( + JsonNullable.of(null), // title -> null + JsonNullable.of(null), // summary -> null + JsonNullable.of(null), // sourceUrl -> null + JsonNullable.of(null), // imageUrl -> null + JsonNullable.of(null), // source -> null + JsonNullable.of(null), // tags -> 전체 삭제 + JsonNullable.of(null) // category -> null + ); + + Integer id = dataSourceService.updateDataSource(memberId, 77, command); - assertThat(id).isEqualTo(7); - assertThat(ds.getTitle()).isEqualTo("new"); - assertThat(ds.getSummary()).isEqualTo("old sum"); // summary 미전달 → 유지 + assertThat(id).isEqualTo(77); + assertThat(ds.getTitle()).isNull(); + assertThat(ds.getSummary()).isNull(); + assertThat(ds.getSourceUrl()).isNull(); + assertThat(ds.getImageUrl()).isNull(); + assertThat(ds.getSource()).isNull(); + assertThat(ds.getCategory()).isNull(); + assertThat(ds.getTags()).isEmpty(); } + @Test + @DisplayName("수정: not present 필드는 변경 없음") + void update_not_present_kept() { + Integer memberId = 1; + Folder f = new Folder("f"); ReflectionTestUtils.setField(f, "id", 10); + DataSource ds = baseDs(f); + + when(dataSourceRepository.findByIdAndMemberId(eq(77), eq(memberId))) + .thenReturn(Optional.of(ds)); + + // title만 present, 나머지는 not present + var command = cmd( + JsonNullable.of("only-title"), + JsonNullable.undefined(), + JsonNullable.undefined(), + JsonNullable.undefined(), + JsonNullable.undefined(), + JsonNullable.undefined(), + JsonNullable.undefined() + ); + + dataSourceService.updateDataSource(memberId, 77, command); + + assertThat(ds.getTitle()).isEqualTo("only-title"); + assertThat(ds.getSummary()).isEqualTo("old-summary"); + assertThat(ds.getSourceUrl()).isEqualTo("http://old.src"); + assertThat(ds.getImageUrl()).isEqualTo("http://old.img"); + assertThat(ds.getSource()).isEqualTo("old-source"); + assertThat(ds.getCategory()).isEqualTo(Category.IT); + assertThat(ds.getTags()).extracting(Tag::getTagName).containsExactlyInAnyOrder("x","y"); + } + + @Test + @DisplayName("수정 성공: tags=[] → 모든 태그 삭제") + void update_tags_empty_clears_all() { + Integer memberId = 1; + Folder f = new Folder("f"); ReflectionTestUtils.setField(f, "id", 10); + DataSource ds = baseDs(f); + + when(dataSourceRepository.findByIdAndMemberId(eq(77), eq(memberId))) + .thenReturn(Optional.of(ds)); + + var command = cmd( + JsonNullable.undefined(), JsonNullable.undefined(), JsonNullable.undefined(), + JsonNullable.undefined(), JsonNullable.undefined(), JsonNullable.of(List.of()), + JsonNullable.undefined() + ); + + dataSourceService.updateDataSource(memberId, 77, command); + + assertThat(ds.getTags()).isEmpty(); + } @Test @DisplayName("수정 실패: 존재하지 않는 자료") void update_notFound() { @@ -707,10 +844,18 @@ void update_notFound() { when(dataSourceRepository.findByIdAndMemberId(anyInt(), eq(memberId))) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> dataSourceService.updateDataSource(memberId, 1, "t", "s")) + var command = cmd( + JsonNullable.of("t"), JsonNullable.of("s"), + JsonNullable.of("u"), JsonNullable.of("i"), + JsonNullable.of("src"), JsonNullable.of(List.of("A")), + JsonNullable.of("IT") + ); + + assertThatThrownBy(() -> dataSourceService.updateDataSource(memberId, 123, command)) .isInstanceOf(NoResultException.class) .hasMessageContaining("존재하지 않는 자료"); } + }