From 17454d51989dd30d530359f09399e86a939c7902 Mon Sep 17 00:00:00 2001 From: jaehunshin Date: Thu, 16 Jul 2026 21:06:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?:bug:=20fix:=20=EA=B3=B5=EC=9C=A0=EC=95=A8?= =?UTF-8?q?=EB=B2=94=20=EB=AA=A9=EB=A1=9D=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?fallback=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/apidoc/06-shared-album.md | 2 +- ...hared-album-photo-ios-integration-guide.md | 2 +- .../dto/response/SharedAlbumListResponse.java | 4 +- .../SharedAlbumPhotoRepository.java | 9 +- .../album/service/SharedAlbumService.java | 8 +- ...edGroupAlbumControllerIntegrationTest.java | 92 +++++++++++++++++-- .../album/service/SharedAlbumServiceTest.java | 15 +-- 7 files changed, 106 insertions(+), 26 deletions(-) diff --git a/docs/apidoc/06-shared-album.md b/docs/apidoc/06-shared-album.md index 7157d91..13632e2 100644 --- a/docs/apidoc/06-shared-album.md +++ b/docs/apidoc/06-shared-album.md @@ -85,7 +85,7 @@ } ``` -`thumbnails`는 그 공유집(앨범)에 **가장 먼저 저장된 사진 순으로 최대 3장**의 썸네일 presigned GET URL이다. 여기서 "가장 먼저 저장된"은 사진이 찍힌 시각이 아니라 **그 사진을 이 앨범에 추가한 시각** 기준이다 — 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이 된다. 썸네일이 아직 준비되지 않은(`thumbnailStatus`가 `READY`가 아닌) 사진은 건너뛰므로 0~3개 사이일 수 있고, 채워서 3개를 맞추지 않는다. `originalUrl`/`thumbnailUrl`과 마찬가지로 호출마다 새로 발급하는 presigned URL이라 영구 저장하지 않는다([07-photo-management.md](07-photo-management.md) 참고). +`thumbnails`는 그 공유집(앨범)에서 **썸네일이 준비된 사진 중 가장 먼저 저장된 순으로 최대 3장**의 썸네일 presigned GET URL이다. 여기서 "가장 먼저 저장된"은 사진이 찍힌 시각이 아니라 **그 사진을 이 앨범에 추가한 시각** 기준이다 — 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이 된다. 썸네일이 아직 준비되지 않은(`thumbnailStatus`가 `READY`가 아닌) 사진은 건너뛰고 뒤의 `READY` 사진으로 최대 3장을 채운다. 준비된 사진 자체가 3장보다 적으면 0~2개일 수 있다. `originalUrl`/`thumbnailUrl`과 마찬가지로 호출마다 새로 발급하는 presigned URL이라 영구 저장하지 않는다([07-photo-management.md](07-photo-management.md) 참고). ### Fail Response diff --git a/docs/apidoc/shared-album-photo-ios-integration-guide.md b/docs/apidoc/shared-album-photo-ios-integration-guide.md index f4101ef..81a4d8f 100644 --- a/docs/apidoc/shared-album-photo-ios-integration-guide.md +++ b/docs/apidoc/shared-album-photo-ios-integration-guide.md @@ -52,7 +52,7 @@ iOS 구현 규칙: **하나의 논리적 요청(사용자의 한 번의 액션) 1. `sharedGroupId`가 존재하고 요청자가 그 그룹의 활성 멤버인지 확인(`SharedAlbumAccessGuard`) — 아니면 `404 SHARED_GROUP_NOT_FOUND`. 2. `createdAt` 내림차순 + 동점 시 앨범 ID 내림차순으로 커서 페이지네이션. 3. 앨범마다 `photoCount`를 그 시점에 실시간 count(비정규화 컬럼 없음 — 저장된 카운터가 아니라 매번 `shared_album_photo` 조인 집계). -4. 앨범마다 **그 앨범에 사진이 추가된 시각**(사진이 찍힌 시각이 아님) 기준으로 가장 먼저 추가된 순 최대 3장을 뽑아 `thumbnails`로 반환한다. 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이다. 썸네일이 아직 `READY`가 아닌 사진은 건너뛰므로(뒤 순번으로 채워 넣지 않음) 0~3개 사이일 수 있다. +4. 앨범마다 썸네일이 `READY`인 사진을 대상으로 **그 앨범에 사진이 추가된 시각**(사진이 찍힌 시각이 아님) 기준 가장 먼저 추가된 순 최대 3장을 `thumbnails`로 반환한다. 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이다. `READY`가 아닌 사진은 건너뛰고 뒤의 `READY` 사진으로 최대 3장을 채우며, 준비된 사진 자체가 부족하면 0~2개일 수 있다. **응답 필드**: `items[].{id, name, photoCount, thumbnails[].{url, urlExpiresAt}, createdBy{userId, displayName}, isCreator, createdAt, updatedAt}`, `nextCursor`, `hasNext`. diff --git a/src/main/java/org/zipzip/zipzipserver/domain/album/dto/response/SharedAlbumListResponse.java b/src/main/java/org/zipzip/zipzipserver/domain/album/dto/response/SharedAlbumListResponse.java index 067343b..35393ff 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/album/dto/response/SharedAlbumListResponse.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/album/dto/response/SharedAlbumListResponse.java @@ -18,8 +18,8 @@ public record Item( @Schema(description = "활성 매핑·활성 사진 기준 실시간 사진 수", example = "42") long photoCount, @Schema( description = - "가장 먼저 저장된 사진 순으로 최대 3장의 썸네일. 썸네일이 아직 준비되지 않은 사진은" - + " 건너뛰므로 개수가 3장보다 적을 수 있습니다.") + "썸네일이 준비된 사진 중 가장 먼저 저장된 순으로 최대 3장. READY가 아닌 사진은 건너뛰고 뒤의 READY" + + " 사진으로 채우며, 준비된 사진이 부족하면 3장보다 적을 수 있습니다.") List thumbnails, @Schema(description = "생성자 요약") SharedAlbumResponse.Creator createdBy, @Schema(description = "요청자 본인이 생성자인지 여부") boolean isCreator, diff --git a/src/main/java/org/zipzip/zipzipserver/domain/album/repository/SharedAlbumPhotoRepository.java b/src/main/java/org/zipzip/zipzipserver/domain/album/repository/SharedAlbumPhotoRepository.java index a817826..d75a1fe 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/album/repository/SharedAlbumPhotoRepository.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/album/repository/SharedAlbumPhotoRepository.java @@ -11,6 +11,7 @@ import org.springframework.data.repository.query.Param; import org.zipzip.zipzipserver.domain.album.entity.SharedAlbumPhoto; import org.zipzip.zipzipserver.domain.photo.entity.Photo; +import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus; public interface SharedAlbumPhotoRepository extends JpaRepository { @@ -91,8 +92,12 @@ List findPageByActiveSharedAlbumId( join sap.photo photo where sap.sharedAlbum.id = :sharedAlbumId and photo.deletedAt is null + and photo.thumbnailStatus = :thumbnailStatus + and photo.thumbnailObjectKey is not null order by sap.createdAt asc, sap.id asc """) - List findOldestPhotosBySharedAlbumId( - @Param("sharedAlbumId") UUID sharedAlbumId, Pageable pageable); + List findOldestPhotosBySharedAlbumIdAndThumbnailStatus( + @Param("sharedAlbumId") UUID sharedAlbumId, + @Param("thumbnailStatus") PhotoThumbnailStatus thumbnailStatus, + Pageable pageable); } diff --git a/src/main/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumService.java b/src/main/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumService.java index 37fda27..21f5696 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumService.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumService.java @@ -287,13 +287,9 @@ private SharedAlbumListResponse.Item toListItem(SharedAlbum album, UUID appUserI private List toThumbnails(UUID albumId) { List oldestPhotos = - sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId( - albumId, PageRequest.of(0, THUMBNAIL_COUNT)); + sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumIdAndThumbnailStatus( + albumId, PhotoThumbnailStatus.READY, PageRequest.of(0, THUMBNAIL_COUNT)); return oldestPhotos.stream() - .filter( - photo -> - photo.getThumbnailStatus() == PhotoThumbnailStatus.READY - && photo.getThumbnailObjectKey() != null) .map( photo -> { PresignedDownload thumbnail = diff --git a/src/test/java/org/zipzip/zipzipserver/domain/album/controller/SharedGroupAlbumControllerIntegrationTest.java b/src/test/java/org/zipzip/zipzipserver/domain/album/controller/SharedGroupAlbumControllerIntegrationTest.java index fd93f36..4f8b2e5 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/album/controller/SharedGroupAlbumControllerIntegrationTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/album/controller/SharedGroupAlbumControllerIntegrationTest.java @@ -144,6 +144,79 @@ void setUp() { .value("https://cdn/third-attached")); } + @Test + void 먼저_추가된_사진의_썸네일이_준비되지_않으면_뒤의_준비된_사진으로_최대_3장을_채운다() throws Exception { + SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId()); + AppUser managedCreator = entityManager.find(AppUser.class, creator.getId()); + SharedAlbum album = persist(SharedAlbum.create(managedGroup, managedCreator, "앨범")); + + Photo pending = aPhoto(managedCreator, Instant.parse("2026-01-01T00:00:00Z")); + Photo failed = aPhoto(managedCreator, Instant.parse("2026-01-02T00:00:00Z")); + failed.markThumbnailFailed(); + Photo anotherFailed = aPhoto(managedCreator, Instant.parse("2026-01-03T00:00:00Z")); + anotherFailed.markThumbnailFailed(); + Photo readyFirst = + aReadyPhoto( + managedCreator, + Instant.parse("2026-01-04T00:00:00Z"), + "thumb/ready-first.jpg"); + Photo readySecond = + aReadyPhoto( + managedCreator, + Instant.parse("2026-01-05T00:00:00Z"), + "thumb/ready-second.jpg"); + Photo readyThird = + aReadyPhoto( + managedCreator, + Instant.parse("2026-01-06T00:00:00Z"), + "thumb/ready-third.jpg"); + persist(pending); + persist(failed); + persist(anotherFailed); + persist(readyFirst); + persist(readySecond); + persist(readyThird); + entityManager.flush(); + + persist(SharedAlbumPhoto.create(album, pending)); + entityManager.flush(); + persist(SharedAlbumPhoto.create(album, failed)); + entityManager.flush(); + persist(SharedAlbumPhoto.create(album, anotherFailed)); + entityManager.flush(); + persist(SharedAlbumPhoto.create(album, readyFirst)); + entityManager.flush(); + persist(SharedAlbumPhoto.create(album, readySecond)); + entityManager.flush(); + persist(SharedAlbumPhoto.create(album, readyThird)); + entityManager.flush(); + entityManager.clear(); + + when(objectStorageService.issueDownloadUrl(eq("thumb/ready-first.jpg"), any())) + .thenReturn(new PresignedDownload("https://cdn/ready-first", Instant.now())); + when(objectStorageService.issueDownloadUrl(eq("thumb/ready-second.jpg"), any())) + .thenReturn(new PresignedDownload("https://cdn/ready-second", Instant.now())); + when(objectStorageService.issueDownloadUrl(eq("thumb/ready-third.jpg"), any())) + .thenReturn(new PresignedDownload("https://cdn/ready-third", Instant.now())); + + mockMvc.perform( + get( + "/api/v1/shared-groups/{sharedGroupId}/shared-albums", + sharedGroup.getId()) + .header("Authorization", bearerToken(creator))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].thumbnails.length()").value(3)) + .andExpect( + jsonPath("$.data.items[0].thumbnails[0].url") + .value("https://cdn/ready-first")) + .andExpect( + jsonPath("$.data.items[0].thumbnails[1].url") + .value("https://cdn/ready-second")) + .andExpect( + jsonPath("$.data.items[0].thumbnails[2].url") + .value("https://cdn/ready-third")); + } + @Test void 사진이_없는_앨범은_빈_썸네일_배열을_반환한다() throws Exception { SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId()); @@ -162,18 +235,21 @@ void setUp() { } private Photo aReadyPhoto(AppUser uploader, Instant takenAt, String thumbnailObjectKey) { - Photo photo = - Photo.create( - uploader, - "iPhone 15", - "photos/" + UUID.randomUUID() + "/original.jpg", - takenAt, - 4032, - 3024); + Photo photo = aPhoto(uploader, takenAt); photo.markThumbnailReady(thumbnailObjectKey); return photo; } + private Photo aPhoto(AppUser uploader, Instant takenAt) { + return Photo.create( + uploader, + "iPhone 15", + "photos/" + UUID.randomUUID() + "/original.jpg", + takenAt, + 4032, + 3024); + } + private String bearerToken(AppUser appUser) { return "Bearer " + jwtTokenProvider.generateAccessToken(appUser.getId()); } diff --git a/src/test/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumServiceTest.java b/src/test/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumServiceTest.java index 1eb54c0..13f48cb 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumServiceTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/album/service/SharedAlbumServiceTest.java @@ -29,6 +29,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; @@ -330,7 +331,8 @@ albumId, requesterId, new SharedAlbumNameRequest(" "))) .thenReturn(List.of(album)); when(sharedAlbumPhotoRepository.countBySharedAlbumIdAndPhotoDeletedAtIsNull(album.getId())) .thenReturn(3L); - when(sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId(eq(album.getId()), any())) + when(sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumIdAndThumbnailStatus( + eq(album.getId()), eq(PhotoThumbnailStatus.READY), any())) .thenReturn(List.of(oldest, middle, newest)); when(objectStorageService.issueDownloadUrl(eq("thumb/oldest.jpg"), any())) .thenReturn(new PresignedDownload("https://cdn/oldest", Instant.now())); @@ -348,12 +350,11 @@ albumId, requesterId, new SharedAlbumNameRequest(" "))) } @Test - void 썸네일이_준비되지_않은_사진은_목록에서_제외된다() { + void 준비된_썸네일_사진만_조회해_목록에_반환한다() { AppUser creator = anAppUser(); SharedGroup group = aSharedGroup(creator); SharedAlbum album = anAlbum(creator, group); UUID requesterId = creator.getId(); - Photo pending = aPhoto(creator); Photo ready = aReadyPhoto(creator, "thumb/ready.jpg"); when(sharedAlbumAccessGuard.requireActiveSharedGroup(group.getId(), requesterId)) @@ -363,8 +364,9 @@ albumId, requesterId, new SharedAlbumNameRequest(" "))) .thenReturn(List.of(album)); when(sharedAlbumPhotoRepository.countBySharedAlbumIdAndPhotoDeletedAtIsNull(album.getId())) .thenReturn(2L); - when(sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId(eq(album.getId()), any())) - .thenReturn(List.of(pending, ready)); + when(sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumIdAndThumbnailStatus( + eq(album.getId()), eq(PhotoThumbnailStatus.READY), any())) + .thenReturn(List.of(ready)); when(objectStorageService.issueDownloadUrl(eq("thumb/ready.jpg"), any())) .thenReturn(new PresignedDownload("https://cdn/ready", Instant.now())); @@ -390,7 +392,8 @@ albumId, requesterId, new SharedAlbumNameRequest(" "))) .thenReturn(List.of(album)); when(sharedAlbumPhotoRepository.countBySharedAlbumIdAndPhotoDeletedAtIsNull(album.getId())) .thenReturn(0L); - when(sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumId(eq(album.getId()), any())) + when(sharedAlbumPhotoRepository.findOldestPhotosBySharedAlbumIdAndThumbnailStatus( + eq(album.getId()), eq(PhotoThumbnailStatus.READY), any())) .thenReturn(List.of()); SharedAlbumListResponse response = From cf99e0d1ed899dc36ff62e3427c09a5ade1cecc1 Mon Sep 17 00:00:00 2001 From: jaehunshin Date: Thu, 16 Jul 2026 21:15:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?:bug:=20fix:=20=EA=B3=B5=EC=9C=A0=EC=95=A8?= =?UTF-8?q?=EB=B2=94=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20fallback=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/apidoc/06-shared-album.md | 2 +- ...hared-album-photo-ios-integration-guide.md | 2 +- .../dto/response/SharedAlbumListResponse.java | 4 +- .../SharedAlbumPhotoRepository.java | 1 + ...edGroupAlbumControllerIntegrationTest.java | 99 +++++++++++++++++++ 5 files changed, 104 insertions(+), 4 deletions(-) diff --git a/docs/apidoc/06-shared-album.md b/docs/apidoc/06-shared-album.md index 13632e2..19ccf00 100644 --- a/docs/apidoc/06-shared-album.md +++ b/docs/apidoc/06-shared-album.md @@ -85,7 +85,7 @@ } ``` -`thumbnails`는 그 공유집(앨범)에서 **썸네일이 준비된 사진 중 가장 먼저 저장된 순으로 최대 3장**의 썸네일 presigned GET URL이다. 여기서 "가장 먼저 저장된"은 사진이 찍힌 시각이 아니라 **그 사진을 이 앨범에 추가한 시각** 기준이다 — 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이 된다. 썸네일이 아직 준비되지 않은(`thumbnailStatus`가 `READY`가 아닌) 사진은 건너뛰고 뒤의 `READY` 사진으로 최대 3장을 채운다. 준비된 사진 자체가 3장보다 적으면 0~2개일 수 있다. `originalUrl`/`thumbnailUrl`과 마찬가지로 호출마다 새로 발급하는 presigned URL이라 영구 저장하지 않는다([07-photo-management.md](07-photo-management.md) 참고). +`thumbnails`는 그 공유집(앨범)에서 **썸네일이 준비된 사진 중 가장 먼저 저장된 순으로 최대 3장**의 썸네일 presigned GET URL이다. 여기서 "가장 먼저 저장된"은 사진이 찍힌 시각이 아니라 **그 사진을 이 앨범에 추가한 시각** 기준이다 — 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이 된다. `thumbnailStatus`가 `READY`가 아니거나 썸네일 오브젝트 키가 없거나 공백인 사진, soft-delete된 사진은 건너뛰고 뒤의 사용 가능한 사진으로 최대 3장을 채운다. 준비된 사진 자체가 3장보다 적으면 0~2개일 수 있다. `originalUrl`/`thumbnailUrl`과 마찬가지로 호출마다 새로 발급하는 presigned URL이라 영구 저장하지 않는다([07-photo-management.md](07-photo-management.md) 참고). ### Fail Response diff --git a/docs/apidoc/shared-album-photo-ios-integration-guide.md b/docs/apidoc/shared-album-photo-ios-integration-guide.md index 81a4d8f..8c9ba21 100644 --- a/docs/apidoc/shared-album-photo-ios-integration-guide.md +++ b/docs/apidoc/shared-album-photo-ios-integration-guide.md @@ -52,7 +52,7 @@ iOS 구현 규칙: **하나의 논리적 요청(사용자의 한 번의 액션) 1. `sharedGroupId`가 존재하고 요청자가 그 그룹의 활성 멤버인지 확인(`SharedAlbumAccessGuard`) — 아니면 `404 SHARED_GROUP_NOT_FOUND`. 2. `createdAt` 내림차순 + 동점 시 앨범 ID 내림차순으로 커서 페이지네이션. 3. 앨범마다 `photoCount`를 그 시점에 실시간 count(비정규화 컬럼 없음 — 저장된 카운터가 아니라 매번 `shared_album_photo` 조인 집계). -4. 앨범마다 썸네일이 `READY`인 사진을 대상으로 **그 앨범에 사진이 추가된 시각**(사진이 찍힌 시각이 아님) 기준 가장 먼저 추가된 순 최대 3장을 `thumbnails`로 반환한다. 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이다. `READY`가 아닌 사진은 건너뛰고 뒤의 `READY` 사진으로 최대 3장을 채우며, 준비된 사진 자체가 부족하면 0~2개일 수 있다. +4. 앨범마다 썸네일이 `READY`이고 유효한 썸네일 오브젝트 키가 있는 활성 사진을 대상으로 **그 앨범에 사진이 추가된 시각**(사진이 찍힌 시각이 아님) 기준 가장 먼저 추가된 순 최대 3장을 `thumbnails`로 반환한다. 기존 사진을 다른 앨범에서 가져와 추가해도(PHOTO-07) 새로 추가한 시점이 기준이다. `READY`가 아니거나 키가 없거나 공백인 사진, soft-delete된 사진은 건너뛰고 뒤의 사용 가능한 사진으로 최대 3장을 채우며, 준비된 사진 자체가 부족하면 0~2개일 수 있다. **응답 필드**: `items[].{id, name, photoCount, thumbnails[].{url, urlExpiresAt}, createdBy{userId, displayName}, isCreator, createdAt, updatedAt}`, `nextCursor`, `hasNext`. diff --git a/src/main/java/org/zipzip/zipzipserver/domain/album/dto/response/SharedAlbumListResponse.java b/src/main/java/org/zipzip/zipzipserver/domain/album/dto/response/SharedAlbumListResponse.java index 35393ff..a9cb3bf 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/album/dto/response/SharedAlbumListResponse.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/album/dto/response/SharedAlbumListResponse.java @@ -18,8 +18,8 @@ public record Item( @Schema(description = "활성 매핑·활성 사진 기준 실시간 사진 수", example = "42") long photoCount, @Schema( description = - "썸네일이 준비된 사진 중 가장 먼저 저장된 순으로 최대 3장. READY가 아닌 사진은 건너뛰고 뒤의 READY" - + " 사진으로 채우며, 준비된 사진이 부족하면 3장보다 적을 수 있습니다.") + "READY 상태이고 유효한 썸네일 오브젝트 키가 있는 사진 중 가장 먼저 저장된 순으로 최대 3장. 사용할 수" + + " 없는 사진은 건너뛰고 뒤의 준비된 사진으로 채우며, 후보가 부족하면 3장보다 적을 수 있습니다.") List thumbnails, @Schema(description = "생성자 요약") SharedAlbumResponse.Creator createdBy, @Schema(description = "요청자 본인이 생성자인지 여부") boolean isCreator, diff --git a/src/main/java/org/zipzip/zipzipserver/domain/album/repository/SharedAlbumPhotoRepository.java b/src/main/java/org/zipzip/zipzipserver/domain/album/repository/SharedAlbumPhotoRepository.java index d75a1fe..ebc1f51 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/album/repository/SharedAlbumPhotoRepository.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/album/repository/SharedAlbumPhotoRepository.java @@ -94,6 +94,7 @@ List findPageByActiveSharedAlbumId( and photo.deletedAt is null and photo.thumbnailStatus = :thumbnailStatus and photo.thumbnailObjectKey is not null + and trim(photo.thumbnailObjectKey) <> '' order by sap.createdAt asc, sap.id asc """) List findOldestPhotosBySharedAlbumIdAndThumbnailStatus( diff --git a/src/test/java/org/zipzip/zipzipserver/domain/album/controller/SharedGroupAlbumControllerIntegrationTest.java b/src/test/java/org/zipzip/zipzipserver/domain/album/controller/SharedGroupAlbumControllerIntegrationTest.java index 4f8b2e5..8371bf8 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/album/controller/SharedGroupAlbumControllerIntegrationTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/album/controller/SharedGroupAlbumControllerIntegrationTest.java @@ -2,6 +2,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -217,6 +218,104 @@ void setUp() { .value("https://cdn/ready-third")); } + @Test + void 사용할_수_없는_썸네일만_있으면_사진이_있어도_빈_배열을_반환한다() throws Exception { + SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId()); + AppUser managedCreator = entityManager.find(AppUser.class, creator.getId()); + SharedAlbum album = persist(SharedAlbum.create(managedGroup, managedCreator, "앨범")); + + Photo pending = aPhoto(managedCreator, Instant.parse("2026-01-01T00:00:00Z")); + Photo failed = aPhoto(managedCreator, Instant.parse("2026-01-02T00:00:00Z")); + failed.markThumbnailFailed(); + Photo readyWithoutKey = + aReadyPhoto(managedCreator, Instant.parse("2026-01-03T00:00:00Z"), null); + Photo readyWithBlankKey = + aReadyPhoto(managedCreator, Instant.parse("2026-01-04T00:00:00Z"), " "); + Photo deletedReady = + aReadyPhoto( + managedCreator, Instant.parse("2026-01-05T00:00:00Z"), "thumb/deleted.jpg"); + deletedReady.delete(Instant.parse("2026-02-01T00:00:00Z")); + persist(pending); + persist(failed); + persist(readyWithoutKey); + persist(readyWithBlankKey); + persist(deletedReady); + entityManager.flush(); + + persist(SharedAlbumPhoto.create(album, pending)); + persist(SharedAlbumPhoto.create(album, failed)); + persist(SharedAlbumPhoto.create(album, readyWithoutKey)); + persist(SharedAlbumPhoto.create(album, readyWithBlankKey)); + persist(SharedAlbumPhoto.create(album, deletedReady)); + entityManager.flush(); + entityManager.clear(); + + mockMvc.perform( + get( + "/api/v1/shared-groups/{sharedGroupId}/shared-albums", + sharedGroup.getId()) + .header("Authorization", bearerToken(creator))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].photoCount").value(4)) + .andExpect(jsonPath("$.data.items[0].thumbnails.length()").value(0)); + verifyNoInteractions(objectStorageService); + } + + @Test + void 준비된_썸네일이_3장보다_적으면_사용할_수_있는_사진만_추가순으로_반환한다() throws Exception { + SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId()); + AppUser managedCreator = entityManager.find(AppUser.class, creator.getId()); + SharedAlbum album = persist(SharedAlbum.create(managedGroup, managedCreator, "앨범")); + + Photo pending = aPhoto(managedCreator, Instant.parse("2026-01-01T00:00:00Z")); + Photo readyFirst = + aReadyPhoto( + managedCreator, + Instant.parse("2026-03-01T00:00:00Z"), + "thumb/ready-first.jpg"); + Photo failed = aPhoto(managedCreator, Instant.parse("2026-01-02T00:00:00Z")); + failed.markThumbnailFailed(); + Photo readySecond = + aReadyPhoto( + managedCreator, + Instant.parse("2026-02-01T00:00:00Z"), + "thumb/ready-second.jpg"); + persist(pending); + persist(readyFirst); + persist(failed); + persist(readySecond); + entityManager.flush(); + + persist(SharedAlbumPhoto.create(album, pending)); + entityManager.flush(); + persist(SharedAlbumPhoto.create(album, readyFirst)); + entityManager.flush(); + persist(SharedAlbumPhoto.create(album, failed)); + entityManager.flush(); + persist(SharedAlbumPhoto.create(album, readySecond)); + entityManager.flush(); + entityManager.clear(); + + when(objectStorageService.issueDownloadUrl(eq("thumb/ready-first.jpg"), any())) + .thenReturn(new PresignedDownload("https://cdn/ready-first", Instant.now())); + when(objectStorageService.issueDownloadUrl(eq("thumb/ready-second.jpg"), any())) + .thenReturn(new PresignedDownload("https://cdn/ready-second", Instant.now())); + + mockMvc.perform( + get( + "/api/v1/shared-groups/{sharedGroupId}/shared-albums", + sharedGroup.getId()) + .header("Authorization", bearerToken(creator))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].thumbnails.length()").value(2)) + .andExpect( + jsonPath("$.data.items[0].thumbnails[0].url") + .value("https://cdn/ready-first")) + .andExpect( + jsonPath("$.data.items[0].thumbnails[1].url") + .value("https://cdn/ready-second")); + } + @Test void 사진이_없는_앨범은_빈_썸네일_배열을_반환한다() throws Exception { SharedGroup managedGroup = entityManager.find(SharedGroup.class, sharedGroup.getId());