Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions src/main/java/com/daytodo/domain/course/entity/MemoryPhoto.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;

Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
import java.util.List;

public interface MemoryPhotoRepository extends JpaRepository<MemoryPhoto, Long> {

List<MemoryPhoto> findAllByDiary_IdOrderByPhotoOrderAsc(Long diaryId);

List<MemoryPhoto> findAllByDiary_Course_CourseIdAndDiary_User_IdOrderByPhotoOrderAsc(Long courseId, Long userId);
// 추억 사진은 코스 멤버 전원이 함께 보는 공용 사진(피그마 '기록' 화면에서 여러 멤버가
// 같은 사진에 메모를 남기는 것으로 확인)이라, diary와 무관하게 course 기준으로 조회한다.
List<MemoryPhoto> findAllByCourse_CourseIdOrderByPhotoOrderAsc(Long courseId);

// 이미 저장된 사진이 있으면 그 뒤 순서부터 부여하기 위해 사용한다. (없으면 0)
@Query("""
Expand All @@ -20,5 +20,4 @@ select coalesce(max(mp.photoOrder), 0)
where mp.course.courseId = :courseId
""")
int findMaxPhotoOrderByCourseId(@Param("courseId") Long courseId);
}

}
26 changes: 24 additions & 2 deletions src/main/java/com/daytodo/domain/course/service/DiaryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<MemoryPhoto> photos = memoryPhotoRepository
.findAllByDiary_Course_CourseIdAndDiary_User_IdOrderByPhotoOrderAsc(courseId, userId);
.findAllByCourse_CourseIdOrderByPhotoOrderAsc(courseId);
return new DiaryResponse.Photos(courseId, toPhotoResponses(photos));
}

Expand All @@ -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<MemoryPhoto> photos = memoryPhotoRepository.findAllByDiary_IdOrderByPhotoOrderAsc(diary.getId());

// diary는 유저가 나중에 코스를 나가도(LEFT) 그대로 남아있으므로, 공용 사진을
// 반환하기 전에 지금도 그 코스의 JOINED 멤버인지 다시 확인한다.
requireCourseMember(userId, diary.getCourse().getCourseId());

// 사진은 diary가 아닌 course 공용이므로, 이 diary가 속한 코스 기준으로 조회한다.
List<MemoryPhoto> photos = memoryPhotoRepository
.findAllByCourse_CourseIdOrderByPhotoOrderAsc(diary.getCourse().getCourseId());
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return new DiaryResponse.MemoryByDate(
diary.getId(),
Expand Down Expand Up @@ -157,6 +171,14 @@ private List<DiaryResponse.Photo> toPhotoResponses(List<MemoryPhoto> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
248 changes: 248 additions & 0 deletions src/test/java/com/daytodo/domain/course/service/DiaryServiceTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading