From b8c6120ca05ae37be8189f0c275a1b384c39286d Mon Sep 17 00:00:00 2001 From: jaehunshin Date: Thu, 16 Jul 2026 22:24:13 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat:=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EB=AA=A9=EB=A1=9D=EC=97=90=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EC=9D=B4=EB=A6=84=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/apidoc/04-shared-group.md | 3 +- .../List Shared Groups.request.yaml | 15 ++++++++ .../controller/SharedGroupController.java | 5 ++- .../response/SharedGroupSummaryResponse.java | 3 ++ .../SharedGroupQueryRepository.java | 22 ++++++++--- .../service/SharedGroupService.java | 1 + .../controller/SharedGroupControllerTest.java | 20 +++++++++- ...haredGroupQueryRepositoryPostgresTest.java | 11 +++++- .../service/SharedGroupServiceTest.java | 32 ++++++++++++++++ .../config/SwaggerOpenApiIntegrationTest.java | 37 +++++++++++++++++++ 10 files changed, 139 insertions(+), 10 deletions(-) diff --git a/docs/apidoc/04-shared-group.md b/docs/apidoc/04-shared-group.md index 4be7cde..52dd7f2 100644 --- a/docs/apidoc/04-shared-group.md +++ b/docs/apidoc/04-shared-group.md @@ -15,7 +15,7 @@ ## 2. GROUP-01 내 공유 그룹 목록 조회 -활성 멤버십의 참여일시 내림차순으로 조회하며 동일 시각은 공유 그룹 ID로 순서를 고정한다. +활성 멤버십의 참여일시 내림차순으로 조회하며 동일 시각은 공유 그룹 ID로 순서를 고정한다. 각 항목의 `memberNames`는 삭제되지 않은 멤버의 이름을 참여일시 오름차순, 멤버십 ID 오름차순으로 반환한다. ### Request @@ -42,6 +42,7 @@ "name": "우리 집", "myRole": "HOST", "memberCount": 4, + "memberNames": ["집집이", "홍길동", "김집집", "이사진"], "sharedAlbumCount": 3, "photoCount": 128, "joinedAt": "2026-07-03T10:15:30Z", diff --git a/postman/collections/ZipZip Auth/Shared Groups/List Shared Groups.request.yaml b/postman/collections/ZipZip Auth/Shared Groups/List Shared Groups.request.yaml index 79ad8e3..e7e9803 100644 --- a/postman/collections/ZipZip Auth/Shared Groups/List Shared Groups.request.yaml +++ b/postman/collections/ZipZip Auth/Shared Groups/List Shared Groups.request.yaml @@ -1,6 +1,7 @@ $kind: http-request url: "{{baseUrl}}/api/v1/shared-groups?size={{pageSize}}" method: GET +description: 각 공유 그룹 항목의 memberNames에서 활성 멤버 이름을 참여 순서대로 확인한다. headers: Authorization: "Bearer {{accessToken}}" scripts: @@ -9,5 +10,19 @@ scripts: pm.test('공유 그룹 목록 조회 응답 상태가 200이다', function () { pm.response.to.have.status(200); }); + + pm.test('각 공유 그룹에 활성 멤버 이름 목록이 포함된다', function () { + const response = pm.response.json(); + const items = response?.data?.items ?? []; + + items.forEach(function (item) { + pm.expect(item).to.have.property('memberNames'); + pm.expect(item.memberNames).to.be.an('array'); + pm.expect(item.memberNames).to.have.lengthOf(item.memberCount); + item.memberNames.forEach(function (memberName) { + pm.expect(memberName).to.be.a('string').and.not.empty; + }); + }); + }); language: text/javascript order: 1000 diff --git a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/controller/SharedGroupController.java b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/controller/SharedGroupController.java index 21f4768..4f86c79 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/controller/SharedGroupController.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/controller/SharedGroupController.java @@ -63,8 +63,9 @@ public class SharedGroupController { @Operation( summary = "내 공유 그룹 목록 조회", description = - "현재 사용자의 활성 멤버십만 참여일시 내림차순으로 조회합니다. 다음 페이지 요청에는 이전" - + " 응답의 nextCursor를 수정하지 않고 그대로 전달합니다.") + "현재 사용자의 활성 멤버십만 참여일시 내림차순으로 조회합니다. 각 항목의 memberNames에는" + + " 삭제되지 않은 멤버의 이름을 참여일시·멤버십 ID 오름차순으로 반환합니다. 다음 페이지" + + " 요청에는 이전 응답의 nextCursor를 수정하지 않고 그대로 전달합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), @ApiResponse(responseCode = "400", description = "유효하지 않은 커서 또는 size"), diff --git a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/dto/response/SharedGroupSummaryResponse.java b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/dto/response/SharedGroupSummaryResponse.java index c70e073..037bbc1 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/dto/response/SharedGroupSummaryResponse.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/dto/response/SharedGroupSummaryResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import java.util.List; import java.util.UUID; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroupRole; @@ -12,6 +13,8 @@ public record SharedGroupSummaryResponse( @Schema(description = "공유 그룹 이름", example = "우리 집") String name, @Schema(description = "요청자의 현재 공유 그룹 역할", example = "HOST") SharedGroupRole myRole, @Schema(description = "활성 멤버 수", example = "4") long memberCount, + @Schema(description = "활성 멤버 이름 목록. 참여일시 오름차순, 멤버십 ID 오름차순", example = "[\"집집이\", \"홍길동\"]") + List memberNames, @Schema(description = "활성 공유집 수", example = "3") long sharedAlbumCount, @Schema(description = "활성 사진 수", example = "128") long photoCount, @Schema(description = "요청자의 그룹 참여 시각(UTC ISO-8601)", example = "2026-07-03T10:15:30Z") diff --git a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/repository/SharedGroupQueryRepository.java b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/repository/SharedGroupQueryRepository.java index 046c53b..f0eed0a 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/repository/SharedGroupQueryRepository.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/repository/SharedGroupQueryRepository.java @@ -4,6 +4,7 @@ import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -48,11 +49,8 @@ public List findMySharedGroups( sgm.role as my_role, sgm.created_at as joined_at, sg.updated_at, - ( - select count(*) - from shared_group_membership member_sgm - where member_sgm.shared_group_id = sg.id - ) as member_count, + coalesce(members.member_count, 0) as member_count, + coalesce(members.member_names, array[]::varchar[]) as member_names, ( select count(*) from shared_album sa @@ -70,6 +68,18 @@ select count(distinct sap.photo_id) ) as photo_count from shared_group_membership sgm join shared_group sg on sg.id = sgm.shared_group_id + left join lateral ( + select + count(*) as member_count, + array_agg( + member_user.display_name + order by member_sgm.created_at asc, member_sgm.id asc + ) as member_names + from shared_group_membership member_sgm + join app_user member_user on member_user.id = member_sgm.app_user_id + where member_sgm.shared_group_id = sg.id + and member_user.deleted_at is null + ) members on true where sgm.app_user_id = :appUserId and sg.deleted_at is null """ @@ -133,6 +143,7 @@ private SharedGroupListRow mapListRow(ResultSet resultSet, int rowNumber) throws resultSet.getString("name"), SharedGroupRole.valueOf(resultSet.getString("my_role")), resultSet.getLong("member_count"), + Arrays.asList((String[]) resultSet.getArray("member_names").getArray()), resultSet.getLong("shared_album_count"), resultSet.getLong("photo_count"), resultSet.getTimestamp("joined_at").toInstant(), @@ -159,6 +170,7 @@ public record SharedGroupListRow( String name, SharedGroupRole myRole, long memberCount, + List memberNames, long sharedAlbumCount, long photoCount, Instant joinedAt, diff --git a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupService.java b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupService.java index dd3ef15..658ae2a 100644 --- a/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupService.java +++ b/src/main/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupService.java @@ -195,6 +195,7 @@ private SharedGroupSummaryResponse toSummaryResponse( row.name(), row.myRole(), row.memberCount(), + row.memberNames(), row.sharedAlbumCount(), row.photoCount(), row.joinedAt(), diff --git a/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/controller/SharedGroupControllerTest.java b/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/controller/SharedGroupControllerTest.java index c62a22e..c718f38 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/controller/SharedGroupControllerTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/controller/SharedGroupControllerTest.java @@ -28,6 +28,7 @@ import org.zipzip.zipzipserver.domain.sharedgroup.code.SharedGroupSuccessCode; import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.CreateSharedGroupResponse; import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.SharedGroupListResponse; +import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.SharedGroupSummaryResponse; import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.SharedGroupUpdateResponse; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroupRole; import org.zipzip.zipzipserver.domain.sharedgroup.service.SharedGroupService; @@ -62,12 +63,29 @@ class SharedGroupControllerTest { @Test void 내_공유_그룹_목록을_조회한다() throws Exception { givenAuthenticatedUser(); + UUID sharedGroupId = UUID.fromString("22222222-2222-2222-2222-222222222222"); when(sharedGroupService.findMySharedGroups(APP_USER_ID, null, null)) - .thenReturn(new SharedGroupListResponse(List.of(), null, false)); + .thenReturn( + new SharedGroupListResponse( + List.of( + new SharedGroupSummaryResponse( + sharedGroupId, + "우리 집", + SharedGroupRole.HOST, + 2, + List.of("집집이", "홍길동"), + 1, + 10, + Instant.parse("2026-07-10T00:00:00Z"), + Instant.parse("2026-07-11T00:00:00Z"))), + null, + false)); mockMvc.perform(get("/api/v1/shared-groups").header("Authorization", bearerToken())) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("SHARED_GROUP_LIST_FOUND")) + .andExpect(jsonPath("$.data.items[0].memberNames[0]").value("집집이")) + .andExpect(jsonPath("$.data.items[0].memberNames[1]").value("홍길동")) .andExpect(jsonPath("$.data.hasNext").value(false)); } diff --git a/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/repository/SharedGroupQueryRepositoryPostgresTest.java b/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/repository/SharedGroupQueryRepositoryPostgresTest.java index 3ca555c..89a1345 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/repository/SharedGroupQueryRepositoryPostgresTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/repository/SharedGroupQueryRepositoryPostgresTest.java @@ -53,6 +53,8 @@ class SharedGroupQueryRepositoryPostgresTest { UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); private static final UUID DELETED_GROUP_MEMBERSHIP_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static final UUID OTHER_MEMBERSHIP_ID = + UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); private static final UUID FIRST_ALBUM_PHOTO_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); private static final UUID SECOND_ALBUM_PHOTO_ID = @@ -84,6 +86,8 @@ static void registerProperties(DynamicPropertyRegistry registry) { assertThat(rows) .extracting(SharedGroupQueryRepository.SharedGroupListRow::id) .containsExactly(FIRST_GROUP_ID, SECOND_GROUP_ID); + assertThat(rows.get(0).memberNames()).containsExactly("집집이", "다른 사용자"); + assertThat(rows.get(0).memberCount()).isEqualTo(2); } @Test @@ -93,7 +97,7 @@ static void registerProperties(DynamicPropertyRegistry registry) { SharedGroupQueryRepository.SharedGroupDetailRow row = sharedGroupQueryRepository.findDetail(APP_USER_ID, FIRST_GROUP_ID).orElseThrow(); - assertThat(row.memberCount()).isEqualTo(1); + assertThat(row.memberCount()).isEqualTo(2); assertThat(row.sharedAlbumCount()).isEqualTo(2); assertThat(row.photoCount()).isEqualTo(1); } @@ -140,6 +144,7 @@ insert into shared_group_membership ( values (?, ?, ?, 'HOST', ?, now()), (?, ?, ?, 'MEMBER', ?, now()), + (?, ?, ?, 'MEMBER', ?, now()), (?, ?, ?, 'MEMBER', ?, now()) on conflict (shared_group_id, app_user_id) do nothing """, @@ -151,6 +156,10 @@ on conflict (shared_group_id, app_user_id) do nothing FIRST_GROUP_ID, APP_USER_ID, Timestamp.from(Instant.parse("2026-07-10T00:00:00Z")), + OTHER_MEMBERSHIP_ID, + FIRST_GROUP_ID, + OTHER_USER_ID, + Timestamp.from(Instant.parse("2026-07-10T00:00:00Z")), DELETED_GROUP_MEMBERSHIP_ID, DELETED_GROUP_ID, APP_USER_ID, diff --git a/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupServiceTest.java b/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupServiceTest.java index cb228e1..54c5d92 100644 --- a/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupServiceTest.java +++ b/src/test/java/org/zipzip/zipzipserver/domain/sharedgroup/service/SharedGroupServiceTest.java @@ -9,6 +9,7 @@ import java.time.Clock; import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; @@ -25,6 +26,7 @@ import org.zipzip.zipzipserver.domain.sharedgroup.code.SharedGroupErrorCode; import org.zipzip.zipzipserver.domain.sharedgroup.dto.request.CreateSharedGroupRequest; import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.CreateSharedGroupResponse; +import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.SharedGroupListResponse; import org.zipzip.zipzipserver.domain.sharedgroup.dto.response.SharedGroupUpdateResponse; import org.zipzip.zipzipserver.domain.sharedgroup.entity.InviteCodeReservation; import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup; @@ -57,6 +59,36 @@ class SharedGroupServiceTest { @InjectMocks private SharedGroupService sharedGroupService; + @Test + void 공유_그룹_목록에_활성_멤버_이름을_반환한다() { + AppUser appUser = AppUser.create("apple-subject", "집집이"); + UUID sharedGroupId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + Instant joinedAt = Instant.parse("2026-07-10T00:00:00Z"); + Instant updatedAt = Instant.parse("2026-07-11T00:00:00Z"); + SharedGroupQueryRepository.SharedGroupListRow row = + new SharedGroupQueryRepository.SharedGroupListRow( + sharedGroupId, + "우리 집", + SharedGroupRole.HOST, + 2, + List.of("집집이", "홍길동"), + 1, + 10, + joinedAt, + updatedAt); + when(appUserRepository.findByIdAndDeletedAtIsNull(APP_USER_ID)) + .thenReturn(Optional.of(appUser)); + when(sharedGroupQueryRepository.findMySharedGroups(APP_USER_ID, null, null, 21)) + .thenReturn(List.of(row)); + + SharedGroupListResponse response = + sharedGroupService.findMySharedGroups(APP_USER_ID, null, null); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).memberNames()).containsExactly("집집이", "홍길동"); + assertThat(response.items().get(0).memberCount()).isEqualTo(2); + } + @Test void 공유_그룹_생성_시_초대_코드와_HOST_멤버십을_생성한다() { AppUser appUser = AppUser.create("apple-subject", "집집이"); diff --git a/src/test/java/org/zipzip/zipzipserver/global/config/SwaggerOpenApiIntegrationTest.java b/src/test/java/org/zipzip/zipzipserver/global/config/SwaggerOpenApiIntegrationTest.java index a50e72f..b25a261 100644 --- a/src/test/java/org/zipzip/zipzipserver/global/config/SwaggerOpenApiIntegrationTest.java +++ b/src/test/java/org/zipzip/zipzipserver/global/config/SwaggerOpenApiIntegrationTest.java @@ -106,6 +106,43 @@ class SwaggerOpenApiIntegrationTest { assertThat(photoOperations.has("delete")).isFalse(); } + @Test + void 공유_그룹_목록_Swagger_스키마는_멤버_이름_목록을_노출한다() throws Exception { + String body = + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + JsonNode document = objectMapper.readTree(body); + + JsonNode memberNamesSchema = + document.path("components") + .path("schemas") + .path("SharedGroupSummaryResponse") + .path("properties") + .path("memberNames"); + JsonNode memberNamesExample = + document.path("paths") + .path("/api/v1/shared-groups") + .path("get") + .path("responses") + .path("200") + .path("content") + .path("application/json") + .path("example") + .path("data") + .path("items") + .path(0) + .path("memberNames"); + + assertThat(memberNamesSchema.path("type").asText()).isEqualTo("array"); + assertThat(memberNamesSchema.path("items").path("type").asText()).isEqualTo("string"); + assertThat(memberNamesSchema.path("description").asText()).contains("참여일시"); + assertThat(memberNamesExample.isArray()).isTrue(); + assertThat(memberNamesExample).isNotEmpty(); + } + private void assertNoDefaultStatusExample(JsonNode node) { if (node.isObject()) { Iterator> fields = node.fields();