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 @@ -26,7 +26,6 @@
import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository;
import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumRepository;
import org.zipzip.zipzipserver.domain.photo.entity.Photo;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus;
import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup;
import org.zipzip.zipzipserver.domain.storage.ObjectStorageService;
Expand Down Expand Up @@ -290,10 +289,7 @@ private List<SharedAlbumListResponse.Thumbnail> toThumbnails(UUID albumId) {
sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId(
albumId, PageRequest.of(0, THUMBNAIL_COUNT));
return oldestPhotos.stream()
.filter(
photo ->
photo.getThumbnailStatus() == PhotoThumbnailStatus.READY
&& photo.getThumbnailObjectKey() != null)
.filter(Photo::hasUsableThumbnail)
.map(
photo -> {
PresignedDownload thumbnail =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,19 @@ public void applyLocation(
}

public void markThumbnailReady(String thumbnailObjectKey) {
if (thumbnailObjectKey == null || thumbnailObjectKey.isBlank()) {
throw new IllegalArgumentException("썸네일 객체 키는 비어 있을 수 없습니다.");
}
this.thumbnailObjectKey = thumbnailObjectKey;
this.thumbnailStatus = PhotoThumbnailStatus.READY;
}

public boolean hasUsableThumbnail() {
return thumbnailStatus == PhotoThumbnailStatus.READY
&& thumbnailObjectKey != null
&& !thumbnailObjectKey.isBlank();
}

public void markThumbnailFailed() {
this.thumbnailStatus = PhotoThumbnailStatus.FAILED;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoListResponse;
import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoMetadataUpdateResponse;
import org.zipzip.zipzipserver.domain.photo.entity.Photo;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus;
import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroupRole;
import org.zipzip.zipzipserver.domain.sharedgroup.repository.SharedGroupMembershipRepository;
Expand Down Expand Up @@ -319,8 +318,7 @@ private PhotoListResponse.PhotoListItem toListItem(
Photo photo, SharedAlbum sharedAlbum, UUID appUserId) {
String thumbnailUrl = null;
Instant thumbnailUrlExpiresAt = null;
if (photo.getThumbnailStatus() == PhotoThumbnailStatus.READY
&& photo.getThumbnailObjectKey() != null) {
if (photo.hasUsableThumbnail()) {
PresignedDownload thumbnail =
objectStorageService.issueDownloadUrl(
photo.getThumbnailObjectKey(), DOWNLOAD_URL_TTL);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository;
import org.zipzip.zipzipserver.domain.photo.code.PhotoErrorCode;
import org.zipzip.zipzipserver.domain.photo.entity.Photo;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus;
import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository;
import org.zipzip.zipzipserver.domain.photo.service.PhotoAccessGuard;
import org.zipzip.zipzipserver.domain.reaction.code.ReactionErrorCode;
Expand Down Expand Up @@ -197,8 +196,7 @@ private Photo requireActivePhoto(UUID photoId) {
}

private PresignedDownload issueThumbnailDownload(Photo photo) {
if (photo.getThumbnailStatus() != PhotoThumbnailStatus.READY
|| photo.getThumbnailObjectKey() == null) {
if (!photo.hasUsableThumbnail()) {
return null;
}
return objectStorageService.issueDownloadUrl(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import org.springframework.transaction.annotation.Transactional;
import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository;
import org.zipzip.zipzipserver.domain.photo.entity.Photo;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus;
import org.zipzip.zipzipserver.domain.sharedgroup.code.SharedGroupErrorCode;
import org.zipzip.zipzipserver.domain.sharedgroup.dto.request.SharedGroupJoinRequest;
import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.InviteCodeResponse;
Expand Down Expand Up @@ -173,8 +172,7 @@ private PresignedDownload findRepresentativeImage(UUID sharedGroupId) {

private PresignedDownload issueRepresentativeImageDownload(Photo photo) {
String objectKey =
photo.getThumbnailStatus() == PhotoThumbnailStatus.READY
&& photo.getThumbnailObjectKey() != null
photo.hasUsableThumbnail()
? photo.getThumbnailObjectKey()
: photo.getOriginalObjectKey();
return objectStorageService.issueDownloadUrl(objectKey, REPRESENTATIVE_IMAGE_URL_TTL);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- 기존의 잘못된 READY 행은 썸네일 재시도 대상인 FAILED로 정리한 뒤,
-- READY 상태가 비어 있지 않은 객체 키를 반드시 갖도록 보장한다.
update photo
set thumbnail_status = 'FAILED'
where thumbnail_status = 'READY'
and (
thumbnail_object_key is null
or thumbnail_object_key ~ '^[[:space:]]*$'
);

alter table photo
add constraint chk_photo__thumbnail_ready_object_key
check (
thumbnail_status <> 'READY'
or (
thumbnail_object_key is not null
and thumbnail_object_key !~ '^[[:space:]]*$'
)
);
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.zipzip.zipzipserver.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import jakarta.persistence.Table;
import java.lang.reflect.Field;
Expand Down Expand Up @@ -99,15 +100,32 @@ void photoThumbnailStatusTransitions() {

assertThat(photo.getThumbnailStatus()).isEqualTo(PhotoThumbnailStatus.PENDING);
assertThat(photo.getThumbnailObjectKey()).isNull();
assertThat(photo.hasUsableThumbnail()).isFalse();

photo.markThumbnailReady("photos/385ff765/thumbnail.jpg");

assertThat(photo.getThumbnailStatus()).isEqualTo(PhotoThumbnailStatus.READY);
assertThat(photo.getThumbnailObjectKey()).isEqualTo("photos/385ff765/thumbnail.jpg");
assertThat(photo.hasUsableThumbnail()).isTrue();

photo.markThumbnailFailed();

assertThat(photo.getThumbnailStatus()).isEqualTo(PhotoThumbnailStatus.FAILED);
assertThat(photo.hasUsableThumbnail()).isFalse();
}

@Test
void photo는_비어있는_썸네일_객체_키로_READY_상태가_될_수_없다() {
AppUser user = AppUser.create("apple-subject", "사용자");
Photo photo = Photo.create(user, null, "photos/385ff765/original.jpg", null, null, null);

assertThatThrownBy(() -> photo.markThumbnailReady(null))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> photo.markThumbnailReady(" \t\n"))
.isInstanceOf(IllegalArgumentException.class);

assertThat(photo.getThumbnailStatus()).isEqualTo(PhotoThumbnailStatus.PENDING);
assertThat(photo.getThumbnailObjectKey()).isNull();
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.zipzip.zipzipserver.domain.album.code.SharedAlbumErrorCode;
import org.zipzip.zipzipserver.domain.album.dto.request.SharedAlbumIdsRequest;
import org.zipzip.zipzipserver.domain.album.dto.request.SharedAlbumNameRequest;
Expand All @@ -29,6 +30,7 @@
import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository;
import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumRepository;
import org.zipzip.zipzipserver.domain.photo.entity.Photo;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus;
import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.InviteCodeReservation;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup;
Expand Down Expand Up @@ -376,6 +378,32 @@ albumId, requesterId, new SharedAlbumNameRequest(" ")))
.containsExactly("https://cdn/ready");
}

@Test
void 공백_키를_가진_기존_READY_썸네일은_목록에서_제외된다() {
AppUser creator = anAppUser();
SharedGroup group = aSharedGroup(creator);
SharedAlbum album = anAlbum(creator, group);
Photo malformed = aPhoto(creator);
ReflectionTestUtils.setField(malformed, "thumbnailStatus", PhotoThumbnailStatus.READY);
ReflectionTestUtils.setField(malformed, "thumbnailObjectKey", " \t");

when(sharedAlbumAccessGuard.requireActiveSharedGroup(group.getId(), creator.getId()))
.thenReturn(group);
when(sharedAlbumRepository.findPageByActiveSharedGroupId(
eq(group.getId()), any(), any(), any()))
.thenReturn(List.of(album));
when(sharedAlbumPhotoRepository.countBySharedAlbumIdAndPhotoDeletedAtIsNull(album.getId()))
.thenReturn(1L);
when(sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId(eq(album.getId()), any()))
.thenReturn(List.of(malformed));

SharedAlbumListResponse response =
sharedAlbumService.listAlbums(group.getId(), creator.getId(), null, null);

assertThat(response.items().getFirst().thumbnails()).isEmpty();
verify(objectStorageService, never()).issueDownloadUrl(eq(" \t"), any());
}

@Test
void 사진이_없는_공유집은_빈_썸네일_목록을_반환한다() {
AppUser creator = anAppUser();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package org.zipzip.zipzipserver.domain.photo.repository;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers(disabledWithoutDocker = true)
class PhotoThumbnailIntegrityMigrationTest {

private static final String PREVIOUS_MIGRATION_VERSION = "20260710140929";

@Container
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");

@Test
void 썸네일_READY_상태는_비어있지_않은_객체_키를_가져야_한다() throws SQLException {
migrateUpToPreviousVersion();
UUID userId = insertUser();
UUID malformedPhotoId = insertPhoto(userId, "READY", " \t\n");

migrateLatest();

assertThat(findThumbnailStatus(malformedPhotoId)).isEqualTo("FAILED");
assertThatThrownBy(() -> insertPhoto(userId, "READY", null))
.isInstanceOf(SQLException.class);
assertThatThrownBy(() -> insertPhoto(userId, "READY", " "))
.isInstanceOf(SQLException.class);
assertThat(insertPhoto(userId, "PENDING", null)).isNotNull();
assertThat(insertPhoto(userId, "READY", "photos/thumbnail.jpg")).isNotNull();
}

private void migrateUpToPreviousVersion() {
Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.target(PREVIOUS_MIGRATION_VERSION)
.load()
.migrate();
}

private void migrateLatest() {
Flyway.configure()
.dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.load()
.migrate();
}

private UUID insertUser() throws SQLException {
UUID userId = UUID.randomUUID();
try (Connection connection = connection();
PreparedStatement statement =
connection.prepareStatement(
"""
insert into app_user (id, apple_subject, display_name)
values (?, ?, '사용자')
""")) {
statement.setObject(1, userId);
statement.setString(2, "apple-subject-" + userId);
statement.executeUpdate();
}
return userId;
}

private UUID insertPhoto(UUID userId, String thumbnailStatus, String thumbnailObjectKey)
throws SQLException {
UUID photoId = UUID.randomUUID();
try (Connection connection = connection();
PreparedStatement statement =
connection.prepareStatement(
"""
insert into photo (
id, uploaded_by_app_user_id, original_object_key, thumbnail_object_key,
thumbnail_status, is_inferred
)
values (?, ?, ?, ?, ?, false)
""")) {
statement.setObject(1, photoId);
statement.setObject(2, userId);
statement.setString(3, "photos/" + photoId + "/original.jpg");
statement.setString(4, thumbnailObjectKey);
statement.setString(5, thumbnailStatus);
statement.executeUpdate();
}
return photoId;
}

private String findThumbnailStatus(UUID photoId) throws SQLException {
try (Connection connection = connection();
PreparedStatement statement =
connection.prepareStatement(
"select thumbnail_status from photo where id = ?")) {
statement.setObject(1, photoId);
try (ResultSet resultSet = statement.executeQuery()) {
resultSet.next();
return resultSet.getString("thumbnail_status");
}
}
}

private Connection connection() throws SQLException {
return DriverManager.getConnection(
POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,24 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.zipzip.zipzipserver.domain.album.entity.SharedAlbum;
import org.zipzip.zipzipserver.domain.album.entity.SharedAlbumPhoto;
import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository;
import org.zipzip.zipzipserver.domain.photo.code.PhotoErrorCode;
import org.zipzip.zipzipserver.domain.photo.dto.request.PhotoIdsRequest;
import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoAttachResponse;
import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoDetachResponse;
import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoListResponse;
import org.zipzip.zipzipserver.domain.photo.dto.response.PhotoMetadataUpdateResponse;
import org.zipzip.zipzipserver.domain.photo.entity.Photo;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus;
import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.InviteCodeReservation;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup;
import org.zipzip.zipzipserver.domain.sharedgroup.repository.SharedGroupMembershipRepository;
import org.zipzip.zipzipserver.domain.storage.ObjectStorageService;
import org.zipzip.zipzipserver.domain.storage.PresignedDownload;
import org.zipzip.zipzipserver.domain.user.entity.AppUser;
import org.zipzip.zipzipserver.global.exception.BusinessException;
import org.zipzip.zipzipserver.global.idempotency.ApiIdempotencyRecord;
Expand Down Expand Up @@ -85,6 +89,27 @@ void setUp() {
.isEqualTo(PhotoErrorCode.NOT_PHOTO_UPLOADER));
}

@Test
void 공백_키를_가진_기존_READY_썸네일에는_URL을_발급하지_않는다() {
Photo photo = aPhoto(uploader);
ReflectionTestUtils.setField(photo, "thumbnailStatus", PhotoThumbnailStatus.READY);
ReflectionTestUtils.setField(photo, "thumbnailObjectKey", " \t");
when(photoAccessGuard.requireActiveSharedAlbum(sharedAlbum.getId(), uploader.getId()))
.thenReturn(sharedAlbum);
when(sharedAlbumPhotoRepository.findPageByActiveSharedAlbumId(
eq(sharedAlbum.getId()), any(), any(), any()))
.thenReturn(List.of(photo));
when(objectStorageService.issueDownloadUrl(eq(photo.getOriginalObjectKey()), any()))
.thenReturn(
new PresignedDownload("https://storage.example/original", Instant.now()));

PhotoListResponse response =
photoService.listPhotos(sharedAlbum.getId(), uploader.getId(), null, null);

assertThat(response.items().getFirst().thumbnailUrl()).isNull();
verify(objectStorageService, never()).issueDownloadUrl(eq(" \t"), any());
}

@Test
void 메타데이터_수정_takenAt_형식이_잘못되면_예외() {
Photo photo = aPhoto(uploader);
Expand Down
Loading
Loading