diff --git a/src/main/java/com/daytodo/domain/course/entity/MemoryPhoto.java b/src/main/java/com/daytodo/domain/course/entity/MemoryPhoto.java index ec0ce99..cb60617 100644 --- a/src/main/java/com/daytodo/domain/course/entity/MemoryPhoto.java +++ b/src/main/java/com/daytodo/domain/course/entity/MemoryPhoto.java @@ -9,10 +9,12 @@ /** * 코스의 추억 사진 * - * TODO(팀 확인 필요): PR #21(auth-diary-api)에도 같은 이름의 엔티티가 있습니다. - * 그쪽은 diary_id 가 NOT NULL 이지만, ERD와 TDY-008 명세는 - * "사진 저장 시점에는 diary_id 를 비워두고 이후 일기 작성 시 연결"이므로 nullable 로 두었습니다. - * ERD에 created_at 이 없어 BaseCreatedEntity 도 상속하지 않았습니다. + * 추억 사진은 코스 멤버 전원이 함께 보는 공용 사진이라(피그마 '기록' 화면에서 여러 멤버가 + * 같은 사진에 메모를 남기는 것으로 확인), course 단위로만 조회하고 diary와는 연관관계를 + * 맺지 않는다. 예전엔 diary_id로 "몇 번째 일기 작성 시점에 확정된 사진인지" 연결하려 했지만, + * 일기를 먼저 쓴 뒤에 사진이 추가되는 순서도 API상 가능해서 diary_id가 영영 비어있는 사진이 + * 생길 수 있었다(PR #49 리뷰, 준열님 코멘트). 조회가 어차피 course 기준이라 diary_id는 + * 실질적으로 쓰이지 않았으므로, 그 원인 자체를 없애기 위해 연관관계를 제거했다. */ @Entity @Table( @@ -38,11 +40,6 @@ public class MemoryPhoto { @JoinColumn(name = "course_id", nullable = false) private Course course; - // 사진 저장 시점에는 비워두고, 해당 날짜의 일기가 작성될 때 연결 - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "diary_id") - private Diary diary; - @Column(name = "image_url", length = 500, nullable = false) private String imageUrl; @@ -55,9 +52,4 @@ public MemoryPhoto(Course course, String imageUrl, Integer photoOrder) { this.imageUrl = imageUrl; this.photoOrder = photoOrder; } - - // 일기 작성 시 해당 일기에 연결 - public void linkDiary(Diary diary) { - this.diary = diary; - } -} +} \ No newline at end of file diff --git a/src/main/java/com/daytodo/domain/course/repository/MemoryPhotoRepository.java b/src/main/java/com/daytodo/domain/course/repository/MemoryPhotoRepository.java index 9984c7a..e27793f 100644 --- a/src/main/java/com/daytodo/domain/course/repository/MemoryPhotoRepository.java +++ b/src/main/java/com/daytodo/domain/course/repository/MemoryPhotoRepository.java @@ -8,10 +8,10 @@ import java.util.List; public interface MemoryPhotoRepository extends JpaRepository { - - List findAllByDiary_IdOrderByPhotoOrderAsc(Long diaryId); - List findAllByDiary_Course_CourseIdAndDiary_User_IdOrderByPhotoOrderAsc(Long courseId, Long userId); + // 추억 사진은 코스 멤버 전원이 함께 보는 공용 사진(피그마 '기록' 화면에서 여러 멤버가 + // 같은 사진에 메모를 남기는 것으로 확인)이라, diary와 무관하게 course 기준으로 조회한다. + List findAllByCourse_CourseIdOrderByPhotoOrderAsc(Long courseId); // 이미 저장된 사진이 있으면 그 뒤 순서부터 부여하기 위해 사용한다. (없으면 0) @Query(""" @@ -20,5 +20,4 @@ select coalesce(max(mp.photoOrder), 0) where mp.course.courseId = :courseId """) int findMaxPhotoOrderByCourseId(@Param("courseId") Long courseId); -} - \ No newline at end of file +} \ No newline at end of file diff --git a/src/main/java/com/daytodo/domain/course/service/DiaryService.java b/src/main/java/com/daytodo/domain/course/service/DiaryService.java index f7d3e1e..0002603 100644 --- a/src/main/java/com/daytodo/domain/course/service/DiaryService.java +++ b/src/main/java/com/daytodo/domain/course/service/DiaryService.java @@ -7,8 +7,10 @@ import com.daytodo.domain.course.entity.Diary; import com.daytodo.domain.course.entity.MemoryPhoto; import com.daytodo.domain.course.enums.CourseStatus; +import com.daytodo.domain.course.enums.MemberStatus; import com.daytodo.domain.course.exception.code.CourseErrorCode; import com.daytodo.domain.course.exception.code.DiaryErrorCode; +import com.daytodo.domain.course.repository.CourseMemberRepository; import com.daytodo.domain.course.repository.CoursePlaceRepository; import com.daytodo.domain.course.repository.CourseRepository; import com.daytodo.domain.course.repository.DiaryRepository; @@ -42,6 +44,7 @@ public class DiaryService { private final CoursePlaceRepository coursePlaceRepository; private final PlaceRepository placeRepository; private final UserRepository userRepository; + private final CourseMemberRepository courseMemberRepository; @Transactional public DiaryResponse.Write writeDiary(Long userId, DiaryRequest.Write request) { @@ -86,8 +89,12 @@ public DiaryResponse.Calendar getCalendar(Long userId, Integer year, Integer mon public DiaryResponse.Photos getPhotosByCourse(Long userId, Long courseId) { getActiveUser(userId); + // 추억 사진은 코스 멤버 전원이 함께 보는 공용 사진이라(피그마 '기록' 화면에서 + // 여러 멤버가 같은 사진에 메모를 남기는 것으로 확인), 요청자가 그 코스의 멤버인지만 + // 확인하고 course 단위로 사진을 조회한다. 특정 멤버의 diary에 연결됐는지는 보지 않는다. + requireCourseMember(userId, courseId); List photos = memoryPhotoRepository - .findAllByDiary_Course_CourseIdAndDiary_User_IdOrderByPhotoOrderAsc(courseId, userId); + .findAllByCourse_CourseIdOrderByPhotoOrderAsc(courseId); return new DiaryResponse.Photos(courseId, toPhotoResponses(photos)); } @@ -99,7 +106,14 @@ public DiaryResponse.MemoryByDate getMemoryByDate(Long userId, LocalDate date) { Diary diary = diaries.stream() .max(Comparator.comparing(Diary::getCreatedAt)) .orElseThrow(() -> new ProjectException(DiaryErrorCode.DIARY_NOT_FOUND)); - List photos = memoryPhotoRepository.findAllByDiary_IdOrderByPhotoOrderAsc(diary.getId()); + + // diary는 유저가 나중에 코스를 나가도(LEFT) 그대로 남아있으므로, 공용 사진을 + // 반환하기 전에 지금도 그 코스의 JOINED 멤버인지 다시 확인한다. + requireCourseMember(userId, diary.getCourse().getCourseId()); + + // 사진은 diary가 아닌 course 공용이므로, 이 diary가 속한 코스 기준으로 조회한다. + List photos = memoryPhotoRepository + .findAllByCourse_CourseIdOrderByPhotoOrderAsc(diary.getCourse().getCourseId()); return new DiaryResponse.MemoryByDate( diary.getId(), @@ -157,6 +171,14 @@ private List toPhotoResponses(List photos) { .toList(); } + private void requireCourseMember(Long userId, Long courseId) { + boolean isMember = courseMemberRepository + .existsByCourseCourseIdAndUserIdAndMemberStatus(courseId, userId, MemberStatus.JOINED); + if (!isMember) { + throw new ProjectException(CourseErrorCode.COURSE_ACCESS_DENIED); + } + } + private User getActiveUser(Long userId) { return userRepository.findByIdAndUserStatus(userId, UserStatus.ACTIVE) .orElseThrow(() -> new ProjectException(DiaryErrorCode.USER_NOT_FOUND)); diff --git a/src/test/java/com/daytodo/domain/course/repository/CourseQueryValidationTest.java b/src/test/java/com/daytodo/domain/course/repository/CourseQueryValidationTest.java index b4194e2..901a122 100644 --- a/src/test/java/com/daytodo/domain/course/repository/CourseQueryValidationTest.java +++ b/src/test/java/com/daytodo/domain/course/repository/CourseQueryValidationTest.java @@ -35,8 +35,9 @@ void queriesRun() { assertThat(courseMemberRepository.findMembersByCourseId(1L, MemberStatus.JOINED)).isEmpty(); assertThat(coursePlaceRepository.findPlacesByCourseId(1L)).isEmpty(); assertThat(memoryPhotoRepository.findMaxPhotoOrderByCourseId(1L)).isZero(); + assertThat(memoryPhotoRepository.findAllByCourse_CourseIdOrderByPhotoOrderAsc(1L)).isEmpty(); assertThat(courseMemberRepository.existsByCourseCourseIdAndUserIdAndMemberStatus( 1L, 1L, MemberStatus.JOINED )).isFalse(); } -} +} \ No newline at end of file diff --git a/src/test/java/com/daytodo/domain/course/service/DiaryServiceTest.java b/src/test/java/com/daytodo/domain/course/service/DiaryServiceTest.java new file mode 100644 index 0000000..7ec7b5c --- /dev/null +++ b/src/test/java/com/daytodo/domain/course/service/DiaryServiceTest.java @@ -0,0 +1,248 @@ +package com.daytodo.domain.course.service; + +import com.daytodo.domain.course.dto.DiaryRequest; +import com.daytodo.domain.course.dto.DiaryResponse; +import com.daytodo.domain.course.entity.Course; +import com.daytodo.domain.course.entity.Diary; +import com.daytodo.domain.course.entity.MemoryPhoto; +import com.daytodo.domain.course.enums.MemberStatus; +import com.daytodo.domain.course.enums.ParticipantType; +import com.daytodo.domain.course.exception.code.CourseErrorCode; +import com.daytodo.domain.course.exception.code.DiaryErrorCode; +import com.daytodo.domain.course.repository.CourseMemberRepository; +import com.daytodo.domain.course.repository.CoursePlaceRepository; +import com.daytodo.domain.course.repository.CourseRepository; +import com.daytodo.domain.course.repository.DiaryRepository; +import com.daytodo.domain.course.repository.MemoryPhotoRepository; +import com.daytodo.domain.place.repository.PlaceRepository; +import com.daytodo.domain.user.entity.User; +import com.daytodo.domain.user.enums.LoginType; +import com.daytodo.domain.user.enums.UserStatus; +import com.daytodo.domain.user.repository.UserRepository; +import com.daytodo.global.apiPayload.exception.ProjectException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DiaryServiceTest { + + private static final Long USER_ID = 1L; + private static final Long COURSE_ID = 10L; + + @Mock DiaryRepository diaryRepository; + @Mock MemoryPhotoRepository memoryPhotoRepository; + @Mock CourseRepository courseRepository; + @Mock CoursePlaceRepository coursePlaceRepository; + @Mock PlaceRepository placeRepository; + @Mock UserRepository userRepository; + @Mock CourseMemberRepository courseMemberRepository; + + DiaryService diaryService; + User user; + Course course; + + @BeforeEach + void setUp() { + diaryService = new DiaryService( + diaryRepository, + memoryPhotoRepository, + courseRepository, + coursePlaceRepository, + placeRepository, + userRepository, + courseMemberRepository + ); + + user = new User("user@example.com", "password", "user", null, LoginType.LOCAL); + ReflectionTestUtils.setField(user, "id", USER_ID); + + course = new Course(user, null, "코스", LocalDate.of(2026, 7, 20), 0, 30000, ParticipantType.FRIEND); + ReflectionTestUtils.setField(course, "courseId", COURSE_ID); + course.complete(); + } + + @Test + void 일기를_새로_작성하면_diary가_생성되고_사진_연동_로직은_더이상_호출되지_않는다() { + // given: 사진(MemoryPhoto)은 diary와 연관관계가 없어졌으므로(코스 공용, PR #49 + // 리뷰에서 준열님이 지적한 "일기 이후 사진 추가" 순서 문제의 근본 원인 제거), + // writeDiary()는 diary 레코드만 만들고 memoryPhotoRepository는 건드리지 않는다. + when(userRepository.findByIdAndUserStatus(USER_ID, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + when(courseRepository.findById(COURSE_ID)).thenReturn(Optional.of(course)); + when(diaryRepository.findByUserIdAndCourse_CourseId(USER_ID, COURSE_ID)).thenReturn(Optional.empty()); + + Diary savedDiary = Diary.builder() + .user(user) + .course(course) + .diaryDate(course.getCourseDate()) + .content("오늘 재밌었다") + .build(); + ReflectionTestUtils.setField(savedDiary, "id", 100L); + when(diaryRepository.save(any(Diary.class))).thenReturn(savedDiary); + + DiaryRequest.Write request = new DiaryRequest.Write(COURSE_ID, "오늘 재밌었다"); + + // when + DiaryResponse.Write response = diaryService.writeDiary(USER_ID, request); + + // then + assertThat(response.diaryId()).isEqualTo(100L); + assertThat(response.courseId()).isEqualTo(COURSE_ID); + verifyNoInteractions(memoryPhotoRepository); + } + + @Test + void 이미_작성된_일기가_있으면_내용만_수정하고_새로_저장하지_않는다() { + // given + when(userRepository.findByIdAndUserStatus(USER_ID, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + when(courseRepository.findById(COURSE_ID)).thenReturn(Optional.of(course)); + + Diary existingDiary = Diary.builder() + .user(user) + .course(course) + .diaryDate(course.getCourseDate()) + .content("이전 내용") + .build(); + ReflectionTestUtils.setField(existingDiary, "id", 200L); + when(diaryRepository.findByUserIdAndCourse_CourseId(USER_ID, COURSE_ID)) + .thenReturn(Optional.of(existingDiary)); + + DiaryRequest.Write request = new DiaryRequest.Write(COURSE_ID, "수정된 내용"); + + // when + DiaryResponse.Write response = diaryService.writeDiary(USER_ID, request); + + // then + assertThat(response.content()).isEqualTo("수정된 내용"); + verify(diaryRepository, never()).save(any(Diary.class)); + verifyNoInteractions(memoryPhotoRepository); + } + + @Test + void 코스_멤버는_diary_소유자가_아니어도_추억사진을_조회할_수_있다() { + // given: 사진은 다른 멤버(=1L이 아닌 다른 유저)의 diary에 연결돼 있어도, + // 요청자가 코스 멤버이기만 하면 course 단위로 사진을 볼 수 있어야 한다. + when(userRepository.findByIdAndUserStatus(USER_ID, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + when(courseMemberRepository.existsByCourseCourseIdAndUserIdAndMemberStatus( + COURSE_ID, USER_ID, MemberStatus.JOINED + )).thenReturn(true); + + MemoryPhoto photo = MemoryPhoto.builder() + .course(course) + .imageUrl("https://example.com/shared.jpg") + .photoOrder(1) + .build(); + when(memoryPhotoRepository.findAllByCourse_CourseIdOrderByPhotoOrderAsc(COURSE_ID)) + .thenReturn(List.of(photo)); + + // when + DiaryResponse.Photos response = diaryService.getPhotosByCourse(USER_ID, COURSE_ID); + + // then + assertThat(response.courseId()).isEqualTo(COURSE_ID); + assertThat(response.photos()).hasSize(1); + } + + @Test + void 코스_멤버가_아니면_추억사진_조회시_접근이_거부된다() { + // given + when(userRepository.findByIdAndUserStatus(USER_ID, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + when(courseMemberRepository.existsByCourseCourseIdAndUserIdAndMemberStatus( + COURSE_ID, USER_ID, MemberStatus.JOINED + )).thenReturn(false); + + // when & then + assertThatThrownBy(() -> diaryService.getPhotosByCourse(USER_ID, COURSE_ID)) + .isInstanceOf(ProjectException.class) + .satisfies(exception -> assertThat(((ProjectException) exception).getErrorCode()) + .isEqualTo(CourseErrorCode.COURSE_ACCESS_DENIED)); + } + + @Test + void 날짜별_추억_조회시_지금도_코스_JOINED_멤버면_공용_사진을_볼_수_있다() { + // given + when(userRepository.findByIdAndUserStatus(USER_ID, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + LocalDate date = course.getCourseDate(); + + Diary diary = Diary.builder() + .user(user) + .course(course) + .diaryDate(date) + .content("오늘 기록") + .build(); + ReflectionTestUtils.setField(diary, "id", 300L); + when(diaryRepository.findAllByUserIdAndDiaryDate(USER_ID, date)).thenReturn(List.of(diary)); + when(courseMemberRepository.existsByCourseCourseIdAndUserIdAndMemberStatus( + COURSE_ID, USER_ID, MemberStatus.JOINED + )).thenReturn(true); + when(memoryPhotoRepository.findAllByCourse_CourseIdOrderByPhotoOrderAsc(COURSE_ID)) + .thenReturn(List.of()); + + // when + DiaryResponse.MemoryByDate response = diaryService.getMemoryByDate(USER_ID, date); + + // then + assertThat(response.diaryId()).isEqualTo(300L); + } + + @Test + void 일기를_썼더라도_지금은_코스에서_나간_멤버면_날짜별_추억_조회가_거부된다() { + // given: diary는 유저가 코스를 나가도(LEFT) 그대로 남아있으므로, diary 소유 + // 여부만으로 공용 사진까지 계속 보여주면 탈퇴 멤버에게도 사진이 새어나간다. + when(userRepository.findByIdAndUserStatus(USER_ID, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + LocalDate date = course.getCourseDate(); + + Diary diary = Diary.builder() + .user(user) + .course(course) + .diaryDate(date) + .content("예전에 쓴 기록") + .build(); + ReflectionTestUtils.setField(diary, "id", 301L); + when(diaryRepository.findAllByUserIdAndDiaryDate(USER_ID, date)).thenReturn(List.of(diary)); + when(courseMemberRepository.existsByCourseCourseIdAndUserIdAndMemberStatus( + COURSE_ID, USER_ID, MemberStatus.JOINED + )).thenReturn(false); + + // when & then + assertThatThrownBy(() -> diaryService.getMemoryByDate(USER_ID, date)) + .isInstanceOf(ProjectException.class) + .satisfies(exception -> assertThat(((ProjectException) exception).getErrorCode()) + .isEqualTo(CourseErrorCode.COURSE_ACCESS_DENIED)); + } + + @Test + void 완료되지_않은_코스면_일기를_작성할_수_없다() { + // given + Course inProgressCourse = new Course( + user, null, "코스", LocalDate.of(2026, 7, 20), 0, 30000, ParticipantType.FRIEND + ); + ReflectionTestUtils.setField(inProgressCourse, "courseId", COURSE_ID); + + when(userRepository.findByIdAndUserStatus(USER_ID, UserStatus.ACTIVE)).thenReturn(Optional.of(user)); + when(courseRepository.findById(COURSE_ID)).thenReturn(Optional.of(inProgressCourse)); + + DiaryRequest.Write request = new DiaryRequest.Write(COURSE_ID, "내용"); + + // when & then + assertThatThrownBy(() -> diaryService.writeDiary(USER_ID, request)) + .isInstanceOf(ProjectException.class) + .satisfies(exception -> assertThat(((ProjectException) exception).getErrorCode()) + .isEqualTo(DiaryErrorCode.COURSE_NOT_COMPLETED)); + } +} \ No newline at end of file