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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.GuestCreateResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkInfoResDTO;
import com.slatto.domain.sharelink.dto.response.ShareLinkResponse.ShareLinkToggleResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.GuestVideoDetailResDTO;
import com.slatto.global.config.ApiErrorCodes;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.slatto.domain.sharelink.service.ShareLinkService;
Expand Down Expand Up @@ -92,6 +93,24 @@ public ApiResponse<GuestCreateResDTO> registerGuest(
);
}

@Operation(
summary = "게스트 영상 상세 조회",
description = "공유 링크를 통해 등록된 게스트가 영상과 프로젝트 정보를 읽기 전용으로 조회합니다."
)
@SecurityRequirements
@ApiErrorCodes({"SHARELINK404", "SHARELINK410", "SHARELINK403", "COMMON404"})
@GetMapping("/share-links/{shareToken}/video")
public ApiResponse<GuestVideoDetailResDTO> getGuestVideo(
@PathVariable String shareToken,
@RequestHeader("X-Guest-Id") Long guestId,
@RequestHeader("X-Guest-Token") String guestToken
) {
return ApiResponse.success(
CommonSuccessCode.OK,
shareLinkService.getGuestVideo(shareToken, guestId, guestToken)
);
}

@Operation(summary = "공유 링크 조회 (소유자용)", description = "영상의 공유 링크를 조회합니다. 프로젝트 멤버만 가능합니다.")
@ApiErrorCodes({"PROJECT403", "SHARELINK404"})
@GetMapping("/videos/{videoId}/share-links")
Expand Down Expand Up @@ -119,4 +138,4 @@ public ApiResponse<ShareLinkToggleResDTO> toggleShareLink(
);
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import com.slatto.domain.sharelink.exception.ShareLinkErrorCode;
import com.slatto.domain.sharelink.repository.ShareLinkRepository;
import com.slatto.domain.video.entity.Video;
import com.slatto.domain.video.dto.response.VideoResponse.GuestVideoDetailResDTO;
import com.slatto.domain.video.service.VideoService;
import com.slatto.global.exception.BaseException;
import com.slatto.global.response.code.CommonErrorCode;
import com.slatto.global.util.TokenHasher;
Expand All @@ -38,6 +40,7 @@ public class ShareLinkService {
private final ObjectProvider<EntityManager> entityManagerProvider;
private final GuestRepository guestRepository;
private final TokenHasher tokenHasher;
private final VideoService videoService;

@Transactional
public ShareLinkCreateResDTO createShareLink(Long videoId, Long userId, ShareLinkCreateReqDTO req) {
Expand Down Expand Up @@ -116,6 +119,29 @@ public GuestCreateResDTO registerGuest(String token, GuestCreateReqDTO req) {
return shareLinkConverter.toGuestCreateResponse(saved, rawSessionToken);
}

@Transactional(readOnly = true)
public GuestVideoDetailResDTO getGuestVideo(String shareToken, Long guestId, String guestToken) {
ShareLink shareLink = shareLinkRepository.findByToken(shareToken)
.orElseThrow(() -> new BaseException(ShareLinkErrorCode.SHARE_LINK_NOT_FOUND));

if (!shareLink.isUsable()) {
throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE);
}

Guest guest = guestRepository.findById(guestId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));

if (guestToken == null || !guest.getSessionToken().equals(tokenHasher.hash(guestToken))) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}

if (!guest.getShareLink().getId().equals(shareLink.getId())) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}

return videoService.getGuestVideoDetail(shareLink.getVideo());
}

@Transactional(readOnly = true)
public ShareLinkInfoResDTO getShareLinkByVideo(Long videoId, Long userId) {

Expand Down Expand Up @@ -167,4 +193,4 @@ public ShareLinkToggleResDTO toggleShareLink(Long shareLinkId, Long userId) {

return shareLinkConverter.toToggleResponse(shareLink);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,40 @@ public static VideoDetailResDTO from(
}
}

@Schema(description = "게스트 영상 상세 조회 응답")
public record GuestVideoDetailResDTO(
@Schema(description = "영상 ID", example = "1") Long videoId,
@Schema(description = "영상 제목", example = "버전명") String title,
@Schema(description = "YouTube 영상 URL", example = "https://www.youtube.com/watch?v=abc123")
String youtubeUrl,
@Schema(description = "YouTube 영상 ID", example = "abc123") String youtubeVideoId,
@Schema(description = "영상 썸네일 URL", example = "https://img.youtube.com/vi/abc123/maxresdefault.jpg")
String thumbnailUrl,
@Schema(description = "영상 진행 상태", example = "IN_PROGRESS") String progressStatus,
@Schema(description = "프로젝트 소개", example = "프로젝트 소개글") String description,
@Schema(description = "영상 메모", example = "영상에 관련된 메모", nullable = true) String memo,
@Schema(description = "프로젝트 태그", example = "[\"뮤직비디오\", \"단편\", \"외주\", \"연출\"]")
List<String> projectTags,
@Schema(description = "생성일", example = "2026-05-20T00:00:00") LocalDateTime createdAt,
@Schema(description = "수정일", example = "2026-05-25T00:00:00") LocalDateTime updatedAt
) {
public static GuestVideoDetailResDTO from(VideoDetailResDTO detail) {
return new GuestVideoDetailResDTO(
detail.videoId(),
detail.title(),
detail.youtubeUrl(),
detail.youtubeVideoId(),
detail.thumbnailUrl(),
detail.progressStatus(),
detail.description(),
detail.memo(),
detail.projectTags(),
detail.createdAt(),
detail.updatedAt()
);
}
}

@Schema(description = "영상 북마크 상태 변경 응답")
public record VideoBookmarkUpdateResDTO(
@Schema(description = "영상 ID", example = "1") Long videoId,
Expand Down
18 changes: 14 additions & 4 deletions src/main/java/com/slatto/domain/video/service/VideoService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.slatto.domain.video.dto.response.VideoResponse.VideoCreateResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoDeleteResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoDetailResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.GuestVideoDetailResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoItemResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoListResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoUpdateResDTO;
Expand Down Expand Up @@ -73,13 +74,22 @@ public VideoDetailResDTO getVideo(Long memberId, Long projectId, Long videoId) {
Video video = videoRepository.findByIdAndProjectId(videoId, projectId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
boolean bookmarked = videoBookmarkRepository.findByVideoIdAndUserId(videoId, memberId).isPresent();
List<String> projectTags = resolveProjectTags(
video.getProject(),
projectAccessRepository.findProjectRoleNames(projectId)
);
VideoDetailResDTO response = createVideoDetailResponse(video, bookmarked);

notificationService.markVideoFeedbackNotificationsAsRead(memberId, videoId);

return response;
}

public GuestVideoDetailResDTO getGuestVideoDetail(Video video) {
return GuestVideoDetailResDTO.from(createVideoDetailResponse(video, false));
}

private VideoDetailResDTO createVideoDetailResponse(Video video, boolean bookmarked) {
List<String> projectTags = resolveProjectTags(
video.getProject(),
projectAccessRepository.findProjectRoleNames(video.getProject().getId())
);
return VideoDetailResDTO.from(video, bookmarked, projectTags);
}

Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/slatto/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
).permitAll()
// 게스트 등록
.requestMatchers(HttpMethod.POST, "/api/v1/share-links/*/guests").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/share-links/*/video").permitAll()

// 게스트 피드백/답글 참여 (조회 포함 — 게스트 소유 검증은 서비스단에서 처리)
.requestMatchers(HttpMethod.POST, "/api/v1/videos/*/feedbacks").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.slatto.domain.sharelink.service;

import com.slatto.domain.project.repository.ProjectMemberRepository;
import com.slatto.domain.sharelink.converter.ShareLinkConverter;
import com.slatto.domain.sharelink.entity.Guest;
import com.slatto.domain.sharelink.entity.ShareLink;
import com.slatto.domain.sharelink.exception.ShareLinkErrorCode;
import com.slatto.domain.sharelink.repository.GuestRepository;
import com.slatto.domain.sharelink.repository.ShareLinkRepository;
import com.slatto.domain.video.dto.response.VideoResponse.GuestVideoDetailResDTO;
import com.slatto.domain.video.entity.Video;
import com.slatto.domain.video.service.VideoService;
import com.slatto.global.exception.BaseException;
import com.slatto.global.response.code.CommonErrorCode;
import com.slatto.global.util.TokenHasher;
import jakarta.persistence.EntityManager;
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.beans.factory.ObjectProvider;

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.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class GuestVideoDetailServiceTest {

private static final String SHARE_TOKEN = "share-token";
private static final String GUEST_TOKEN = "guest-token";
private static final Long GUEST_ID = 20L;

@Mock private ShareLinkRepository shareLinkRepository;
@Mock private ShareLinkConverter shareLinkConverter;
@Mock private ProjectMemberRepository projectMemberRepository;
@Mock private ObjectProvider<EntityManager> entityManagerProvider;
@Mock private GuestRepository guestRepository;
@Mock private VideoService videoService;
@Mock private ShareLink shareLink;
@Mock private ShareLink anotherShareLink;
@Mock private Guest guest;
@Mock private Video video;

private final TokenHasher tokenHasher = new TokenHasher();
private ShareLinkService shareLinkService;

@BeforeEach
void setUp() {
shareLinkService = new ShareLinkService(
shareLinkRepository,
shareLinkConverter,
projectMemberRepository,
entityManagerProvider,
guestRepository,
tokenHasher,
videoService
);
}

@Test
void returnsGuestVideoDetailAfterValidatingLinkAndGuest() {
GuestVideoDetailResDTO expected = new GuestVideoDetailResDTO(
1L, "1차 편집본", "https://youtube.example/video", "video-id", "thumbnail",
"IN_PROGRESS", "프로젝트 소개", "메모", List.of("광고"), null, null
);
givenValidAccess();
given(shareLink.getVideo()).willReturn(video);
given(videoService.getGuestVideoDetail(video)).willReturn(expected);

GuestVideoDetailResDTO result = shareLinkService.getGuestVideo(SHARE_TOKEN, GUEST_ID, GUEST_TOKEN);

assertThat(result).isSameAs(expected);
verify(videoService).getGuestVideoDetail(video);
}

@Test
void rejectsUnknownShareLink() {
given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.empty());

assertError(ShareLinkErrorCode.SHARE_LINK_NOT_FOUND, SHARE_TOKEN, GUEST_ID, GUEST_TOKEN);
verify(guestRepository, never()).findById(GUEST_ID);
}

@Test
void rejectsUnavailableShareLink() {
given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.of(shareLink));
given(shareLink.isUsable()).willReturn(false);

assertError(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE, SHARE_TOKEN, GUEST_ID, GUEST_TOKEN);
verify(guestRepository, never()).findById(GUEST_ID);
}

@Test
void rejectsUnknownGuest() {
given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.of(shareLink));
given(shareLink.isUsable()).willReturn(true);
given(guestRepository.findById(GUEST_ID)).willReturn(Optional.empty());

assertError(CommonErrorCode.NOT_FOUND, SHARE_TOKEN, GUEST_ID, GUEST_TOKEN);
}

@Test
void rejectsInvalidGuestToken() {
given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.of(shareLink));
given(shareLink.isUsable()).willReturn(true);
given(guestRepository.findById(GUEST_ID)).willReturn(Optional.of(guest));
given(guest.getSessionToken()).willReturn(tokenHasher.hash(GUEST_TOKEN));

assertError(ShareLinkErrorCode.GUEST_ACCESS_DENIED, SHARE_TOKEN, GUEST_ID, "wrong-token");
verify(videoService, never()).getGuestVideoDetail(video);
}

@Test
void rejectsGuestRegisteredThroughAnotherShareLink() {
givenValidAccess();
given(anotherShareLink.getId()).willReturn(2L);
given(guest.getShareLink()).willReturn(anotherShareLink);

assertError(ShareLinkErrorCode.GUEST_ACCESS_DENIED, SHARE_TOKEN, GUEST_ID, GUEST_TOKEN);
verify(videoService, never()).getGuestVideoDetail(video);
}

private void givenValidAccess() {
given(shareLinkRepository.findByToken(SHARE_TOKEN)).willReturn(Optional.of(shareLink));
given(shareLink.isUsable()).willReturn(true);
given(shareLink.getId()).willReturn(1L);
given(guestRepository.findById(GUEST_ID)).willReturn(Optional.of(guest));
given(guest.getSessionToken()).willReturn(tokenHasher.hash(GUEST_TOKEN));
given(guest.getShareLink()).willReturn(shareLink);
}

private void assertError(Object expectedCode, String shareToken, Long guestId, String guestToken) {
assertThatThrownBy(() -> shareLinkService.getGuestVideo(shareToken, guestId, guestToken))
.isInstanceOfSatisfying(BaseException.class,
exception -> assertThat(exception.getErrorCode()).isSameAs(expectedCode));
}
}
Loading