From d02581e2219408e7a8414a60f0397655e6a69588 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sun, 10 Aug 2025 01:22:12 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20test=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nowait-app-admin-api/build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nowait-app-admin-api/build.gradle b/nowait-app-admin-api/build.gradle index b2b40a9c..3a65d7ac 100644 --- a/nowait-app-admin-api/build.gradle +++ b/nowait-app-admin-api/build.gradle @@ -76,7 +76,12 @@ dependencies { // prometheus implementation 'io.micrometer:micrometer-registry-prometheus' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation platform("org.testcontainers:testcontainers-bom:1.20.1") + testImplementation "org.testcontainers:junit-jupiter" + testImplementation "org.testcontainers:testcontainers" + } test { From 6f0a31ca2640405b1cd5918133465f045e04ccff Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sun, 10 Aug 2025 01:22:29 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test(Reservation):=20=EC=9C=A0=EB=8B=9B=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/ReservationServiceTest.java | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/ReservationServiceTest.java diff --git a/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/ReservationServiceTest.java b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/ReservationServiceTest.java new file mode 100644 index 00000000..c3757ca5 --- /dev/null +++ b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/ReservationServiceTest.java @@ -0,0 +1,298 @@ +package com.nowait.applicationadmin.reservation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.stubbing.Answer; +import org.springframework.data.redis.core.DefaultTypedTuple; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import com.nowait.applicationadmin.reservation.dto.EntryStatusResponseDto; +import com.nowait.applicationadmin.reservation.dto.WaitingUserResponse; +import com.nowait.applicationadmin.reservation.repository.WaitingRedisRepository; +import com.nowait.applicationadmin.reservation.service.ReservationService; +import com.nowait.common.enums.ReservationStatus; +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.domaincorerdb.reservation.entity.Reservation; +import com.nowait.domaincorerdb.reservation.repository.ReservationRepository; +import com.nowait.domaincorerdb.store.entity.Store; +import com.nowait.domaincorerdb.store.repository.StoreRepository; +import com.nowait.domaincorerdb.user.entity.MemberDetails; +import com.nowait.domaincorerdb.user.entity.User; +import com.nowait.domaincorerdb.user.repository.UserRepository; + +public class ReservationServiceTest { + + @Mock + private ReservationRepository reservationRepository; + @Mock + private UserRepository userRepository; + @Mock + private WaitingRedisRepository waitingRedisRepository; + @Mock + private StoreRepository storeRepository; + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ZSetOperations zSetOps; + + + @InjectMocks + private ReservationService reservationService; + + private final Long storeId = 23L; + private final String userId = "10"; + private final AtomicLong seq = new AtomicLong(1000L); + private MemberDetails adminPrincipal; + private Store storeRef; + + private Answer stubSaveAssigningId() { + return inv -> { + Reservation r = inv.getArgument(0); + // 새 객체 만들어 ID 포함해서 반환 + return Reservation.builder() + .id(seq.incrementAndGet()) + .reservationNumber(r.getReservationNumber()) + .store(r.getStore()) + .user(r.getUser()) + .requestedAt(r.getRequestedAt()) + .updatedAt(r.getUpdatedAt()) + .status(r.getStatus()) + .partySize(r.getPartySize()) + .build(); + }; + } + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + adminPrincipal = MemberDetails.builder() + .id(1L).email("admin@test.com").password("pw") + .authorities(List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))) + .build(); + + User adminUser = User.createUserWithId( + 1L, "admin@test.com", "admin", "img", + SocialType.LOCAL, Role.SUPER_ADMIN, storeId + ); + + given(userRepository.findById(1L)).willReturn(Optional.of(adminUser)); + + storeRef = Store.builder() + .storeId(storeId) + .departmentId(10L) + .name("S") + .location("L") + .description("D") + .noticeTitle("NT") + .noticeContent("NC") + .openTime("10001100") + .isActive(true) + .deleted(false) + .build(); + + given(storeRepository.getReferenceById(storeId)).willReturn(storeRef); + + given(redisTemplate.opsForZSet()).willReturn(zSetOps); + } + + @Test + @DisplayName("WAITING → CALLING") + void process_waiting_to_calling() { + // given + given(waitingRedisRepository.getReservationId(storeId, userId)).willReturn("R-001"); + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("WAITING"); + given(waitingRedisRepository.getWaitingPartySize(storeId, userId)).willReturn(3); + given(zSetOps.score("waiting:" + storeId, userId)).willReturn((double) System.currentTimeMillis()); + User userRef = User.createUserWithId(200L, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId); + given(userRepository.getReferenceById(anyLong())).willReturn(userRef); + + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, userId, adminPrincipal, ReservationStatus.CALLING); + + // then + assertThat(dto.getStatus()).isEqualTo("CALLING"); + assertThat(dto.getReservationNumber()).isEqualTo("R-001"); + then(waitingRedisRepository).should().setWaitingStatus(storeId, userId, "CALLING"); + then(waitingRedisRepository).should().setWaitingCalledAt(eq(storeId), eq(userId), anyLong()); + then(reservationRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("WAITING → CONFIRMED : DB 저장 + Redis 삭제") + void process_waiting_to_confirmed() { + // given + given(waitingRedisRepository.getReservationId(storeId, userId)).willReturn("R-002"); + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("WAITING"); + given(waitingRedisRepository.getWaitingPartySize(storeId, userId)).willReturn(2); + given(zSetOps.score("waiting:" + storeId, userId)).willReturn((double) System.currentTimeMillis()); + given(userRepository.getReferenceById(anyLong())) + .willReturn(User.createUserWithId(200L, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId)); + + AtomicReference savedRef = new AtomicReference<>(); + given(reservationRepository.save(any(Reservation.class))).willAnswer(inv -> { + Reservation saved = stubSaveAssigningId().answer(inv); // ★ ID 있는 객체 + savedRef.set(saved); + return saved; + }); + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, userId, adminPrincipal, ReservationStatus.CONFIRMED); + + // then + then(waitingRedisRepository).should().deleteWaiting(storeId, userId); + assertThat(savedRef.get().getStatus()).isEqualTo(ReservationStatus.CONFIRMED); + assertThat(dto.getStatus()).isEqualTo("CONFIRMED"); + assertThat(dto.getReservationNumber()).isEqualTo("R-002"); + } + + + @Test + @DisplayName("CALLING → CONFIRMED : DB 저장 + Redis 삭제") + void process_calling_to_confirmed() { + // given + given(waitingRedisRepository.getReservationId(storeId, userId)).willReturn("R-003"); + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("CALLING"); + given(waitingRedisRepository.getWaitingPartySize(storeId, userId)).willReturn(4); + given(zSetOps.score("waiting:" + storeId, userId)).willReturn((double) System.currentTimeMillis()); + given(userRepository.getReferenceById(anyLong())) + .willReturn(User.createUserWithId(200L, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId)); + given(reservationRepository.save(any(Reservation.class))).willAnswer(stubSaveAssigningId()); // ★ + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, userId, adminPrincipal, ReservationStatus.CONFIRMED); + + // then + then(waitingRedisRepository).should().deleteWaiting(storeId, userId); + assertThat(dto.getStatus()).isEqualTo("CONFIRMED"); + } + + + @Test + @DisplayName("CANCELLED 상태에서 CALLING 시도 → 예외") + void process_cancelled_to_calling_illegal() { + // given + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("CANCELLED"); + + // when / then + assertThatThrownBy(() -> + reservationService.processEntryStatus(storeId, userId, adminPrincipal, ReservationStatus.CALLING) + ).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("WAITING → CANCELLED : DB 저장 + Redis 삭제") + void process_waiting_to_cancelled() { + // given + given(waitingRedisRepository.getReservationId(storeId, userId)).willReturn("R-004"); + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("WAITING"); + given(waitingRedisRepository.getWaitingPartySize(storeId, userId)).willReturn(2); + given(zSetOps.score("waiting:" + storeId, userId)).willReturn((double) System.currentTimeMillis()); + given(userRepository.getReferenceById(anyLong())) + .willReturn(User.createUserWithId(200L, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId)); + given(reservationRepository.save(any(Reservation.class))).willAnswer(stubSaveAssigningId()); // ★ + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, userId, adminPrincipal, ReservationStatus.CANCELLED); + + // then + then(waitingRedisRepository).should().deleteWaiting(storeId, userId); + assertThat(dto.getStatus()).isEqualTo("CANCELLED"); + } + + + @Test + @DisplayName("이미 CANCELLED 기록 존재 시 → CONFIRMED로 전환 (DB 조회 분기)") + void process_from_cancelled_record_to_confirmed() { + // given + given(waitingRedisRepository.getWaitingStatus(storeId, userId)).willReturn("CANCELLED"); + Long uid = 200L; + + User userRef = User.createUserWithId(uid, "u@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId); + Reservation cancelled = Reservation.builder() + .id(777L) + .reservationNumber("R-005") + .store(storeRef) + .user(userRef) + .requestedAt(LocalDateTime.now().minusMinutes(30)) + .updatedAt(LocalDateTime.now().minusMinutes(20)) + .status(ReservationStatus.CANCELLED) + .partySize(3) + .build(); + + given(reservationRepository.findFirstByStore_StoreIdAndUserIdAndStatusInAndRequestedAtBetweenOrderByRequestedAtDesc( + eq(storeId), eq(uid), eq(List.of(ReservationStatus.CANCELLED)), any(), any() + )).willReturn(Optional.of(cancelled)); + given(userRepository.getReferenceById(anyLong())).willReturn(userRef); + given(reservationRepository.save(any(Reservation.class))).willAnswer(stubSaveAssigningId()); // ★ + + // when + EntryStatusResponseDto dto = reservationService.processEntryStatus( + storeId, String.valueOf(uid), adminPrincipal, ReservationStatus.CONFIRMED); + + // then + then(reservationRepository).should().save(any(Reservation.class)); + assertThat(dto.getStatus()).isEqualTo("CONFIRMED"); + assertThat(dto.getReservationNumber()).isEqualTo("R-005"); + } + + + @Test + @DisplayName("getAllWaitingUserDetails : 파이프라인 매핑") + void get_all_waiting_pipeline_mapping() { + // given + long t1 = System.currentTimeMillis() - 1000; + long t2 = System.currentTimeMillis(); + var a = new DefaultTypedTuple<>("200", (double) t1); + var b = new DefaultTypedTuple<>("201", (double) t2); + given(waitingRedisRepository.getAllWaitingWithScore(storeId)).willReturn(List.of(a, b)); + + given(userRepository.findAllById(Arrays.asList(200L, 201L))) + .willReturn(List.of( + User.createUserWithId(200L, "u1@a", "neo", "img", SocialType.KAKAO, Role.USER, storeId), + User.createUserWithId(201L, "u2@a", "trinity", "img", SocialType.KAKAO, Role.USER, storeId) + )); + + List pipeline = new ArrayList<>(); + pipeline.add("3"); pipeline.add("WAITING"); pipeline.add("R-100"); pipeline.add(String.valueOf(t1)); pipeline.add((double) t1); + pipeline.add("2"); pipeline.add("CALLING"); pipeline.add("R-101"); pipeline.add(String.valueOf(t2)); pipeline.add((double) t2); + given(redisTemplate.executePipelined(any(RedisCallback.class))).willReturn(pipeline); + + // when + List list = reservationService.getAllWaitingUserDetails(storeId); + + // then + assertThat(list).hasSize(2); + assertThat(list.get(0).getUserId()).isEqualTo("200"); + assertThat(list.get(0).getUserName()).isEqualTo("neo"); + assertThat(list.get(0).getReservationNumber()).isEqualTo("R-100"); + assertThat(list.get(0).getStatus()).isEqualTo("WAITING"); + assertThat(list.get(1).getStatus()).isEqualTo("CALLING"); + assertThat(list.get(0).getCreatedAt()).isBefore(list.get(1).getCreatedAt()); + } +} From 2d365e466e2b2a512bbdd9aacdb79ff210e9d924 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sun, 10 Aug 2025 01:22:32 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test(Reservation):=20=EC=9C=A0=EB=8B=9B=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WaitingRedisRepositoryTest.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingRedisRepositoryTest.java diff --git a/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingRedisRepositoryTest.java b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingRedisRepositoryTest.java new file mode 100644 index 00000000..841c1ea7 --- /dev/null +++ b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingRedisRepositoryTest.java @@ -0,0 +1,75 @@ +package com.nowait.applicationadmin.reservation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; + +import com.nowait.applicationadmin.reservation.repository.WaitingRedisRepository; + +public class WaitingRedisRepositoryTest { + @Mock + private StringRedisTemplate template; + @Mock private ZSetOperations zOps; + @Mock private HashOperations hOps; + + @InjectMocks + private WaitingRedisRepository repo; + + @BeforeEach + void init() { + MockitoAnnotations.openMocks(this); + given(template.opsForZSet()).willReturn(zOps); + given(template.opsForHash()).willReturn(hOps); + } + + @Test + @DisplayName("getAllWaitingWithScore : null-safe") + void get_all_waiting_with_score_null_safe() { + // given + given(zOps.rangeWithScores("waiting:100", 0, -1)).willReturn(null); + + // when + var res = repo.getAllWaitingWithScore(100L); + + // then + assertThat(res).isEmpty(); + } + + @Test + @DisplayName("getWaitingStatus : null-safe") + void get_waiting_status_null_safe() { + // given + given(hOps.get("waiting:status:100", "u")).willReturn(null); + + // when + var v = repo.getWaitingStatus(100L, "u"); + + // then + assertThat(v).isNull(); + } + + @Test + @DisplayName("deleteWaiting : 모든 관련 키 삭제") + void delete_waiting_remove_all_keys() { + // given (nothing) + + // when + repo.deleteWaiting(100L, "u"); + + // then + then(hOps).should().delete("waiting:status:100", "u"); + then(zOps).should().remove("waiting:100", "u"); + then(hOps).should().delete("waiting:party:100", "u"); + then(hOps).should().delete("reservation:number:100", "u"); + then(hOps).should().delete("waiting:calledAt:100", "u"); + } +} From efcc884286ca63f57b78970a931bf339b189fa52 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Sun, 10 Aug 2025 01:22:47 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test(Reservation):=20=EC=98=88=EC=99=B8=20c?= =?UTF-8?q?ase=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/WaitingDuplicateIT.java | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingDuplicateIT.java diff --git a/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingDuplicateIT.java b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingDuplicateIT.java new file mode 100644 index 00000000..f0c43b82 --- /dev/null +++ b/nowait-app-admin-api/src/test/java/com/nowait/applicationadmin/reservation/WaitingDuplicateIT.java @@ -0,0 +1,180 @@ +package com.nowait.applicationadmin.reservation; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import com.nowait.domaincoreredis.common.util.RedisKeyUtils; + +@Testcontainers +public class WaitingDuplicateIT { + @Container + static GenericContainer redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379); + + StringRedisTemplate tpl; + + Long storeId = 100L; + String zKey, stKey, partyKey, numKey, calledKey; + + @BeforeEach + void init() { + var cfg = new RedisStandaloneConfiguration("localhost", redis.getMappedPort(6379)); + var lf = new LettuceConnectionFactory(cfg); + lf.afterPropertiesSet(); + tpl = new StringRedisTemplate(lf); + + zKey = RedisKeyUtils.buildWaitingKeyPrefix() + storeId; // waiting:{storeId} + stKey = RedisKeyUtils.buildWaitingStatusKeyPrefix() + storeId; // waiting:status:{storeId} + partyKey = RedisKeyUtils.buildWaitingPartySizeKeyPrefix() + storeId; // waiting:party:{storeId} + numKey = RedisKeyUtils.buildReservationNumberKey(storeId); // reservation:number:{storeId} + calledKey = RedisKeyUtils.buildWaitingCalledAtKeyPrefix() + storeId; // waiting:calledAt:{storeId} + + // clean + tpl.delete(List.of(zKey, stKey, partyKey, numKey, calledKey)); + } + + // ===== Helper: 현재 목록 덤프 ===== + private List dump() { + Set ids = tpl.opsForZSet().range(zKey, 0, -1); + if (ids == null) return List.of(); + List out = new ArrayList<>(); + for (String uid : ids) { + String status = (String) tpl.opsForHash().get(stKey, uid); + String party = (String) tpl.opsForHash().get(partyKey, uid); + String num = (String) tpl.opsForHash().get(numKey, uid); + out.add(uid + "|" + status + "|" + party + "|" + num); + } + return out; + } + + // ====== 시나리오 A: ID 정규화 실패(공백 차이) ====== + @Test + @DisplayName("A) userId 공백/포맷 불일치 → 사실상 같은 팀이 두 줄로 보임") + void duplicate_by_userid_format() { + // given + String uid1 = "200"; + String uid2 = " 200"; // 앞 공백 (의도적인 비정규화) + long now = System.currentTimeMillis(); + + // when + tpl.opsForZSet().add(zKey, uid1, now); + tpl.opsForHash().put(stKey, uid1, "WAITING"); + tpl.opsForHash().put(partyKey, uid1, "3"); + tpl.opsForHash().put(numKey, uid1, "R-100"); + + tpl.opsForZSet().add(zKey, uid2, now + 1); // 공백 다른 멤버 → ZSET에선 “다른 사용자” + tpl.opsForHash().put(stKey, uid2, "WAITING"); + tpl.opsForHash().put(partyKey, uid2, "3"); + tpl.opsForHash().put(numKey, uid2, "R-100"); // 같은 예약번호까지 + + // then + List view = dump(); + assertThat(view).hasSize(2); // UI 관점엔 중복으로 보임 + // 방어 로직 없다면 동일 사용자로 인식될 수 있음 + } + + // ====== 시나리오 B: 예약번호 충돌(비원자 생성/할당 가정) ====== + @Test + @DisplayName("B) 예약번호 충돌 → 서로 다른 userId가 같은 reservationNumber로 노출") + void duplicate_by_reservation_number_collision() throws Exception { + // given + String u1 = "201"; + String u2 = "202"; + long t = System.currentTimeMillis(); + + // when (의도적으로 같은 번호 세팅) + tpl.opsForZSet().add(zKey, u1, t); + tpl.opsForHash().put(stKey, u1, "WAITING"); + tpl.opsForHash().put(partyKey, u1, "2"); + tpl.opsForHash().put(numKey, u1, "R-200"); + + tpl.opsForZSet().add(zKey, u2, t + 1); + tpl.opsForHash().put(stKey, u2, "WAITING"); + tpl.opsForHash().put(partyKey, u2, "2"); + tpl.opsForHash().put(numKey, u2, "R-200"); // 충돌 + + // then + List view = dump(); + assertThat(view).filteredOn(s -> s.endsWith("|R-200")).hasSize(2); // 같은 번호 2개 + // DB unique는 막지만 Redis 뷰에선 중복 그대로 노출됨 + } + + // ====== 시나리오 C: 부분 삭제 레이스(유령 조각) ====== + @Test + @DisplayName("C) 부분 삭제 실패 → 찌꺼기 남아 중복/유령 항목 노출") + void ghost_after_partial_delete() { + // given + String uid = "203"; + long t = System.currentTimeMillis(); + tpl.opsForZSet().add(zKey, uid, t); + tpl.opsForHash().put(stKey, uid, "WAITING"); + tpl.opsForHash().put(partyKey, uid, "4"); + tpl.opsForHash().put(numKey, uid, "R-300"); + + // when (의도적으로 해시만 지우고 ZSET은 남김 = 네 현재 deleteWaiting 순차 호출이 실패한 상황을 흉내) + tpl.opsForHash().delete(stKey, uid); + tpl.opsForHash().delete(partyKey, uid); + tpl.opsForHash().delete(numKey, uid); + // tpl.opsForZSet().remove(zKey, uid); // 실패했다고 가정 + + // then + var ids = tpl.opsForZSet().range(zKey, 0, -1); + assertThat(ids).contains(uid); // 큐엔 남아있음 + List view = dump(); + // status/party/number가 null → 조회/매핑에서 NPE or 비정상 라인 가능 + assertThat(view.get(0)).contains("null"); + } + + // ====== 시나리오 D(선택): 동시 등록 레이스 증폭 ====== + @Test + @DisplayName("D) 동시 등록 100개(같은 사용자/다른 포맷) → 중복 노출 가능성 확인") + void concurrent_register_format_mixture() throws Exception { + // given + ExecutorService es = Executors.newFixedThreadPool(16); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(100); + + // when + for (int i = 0; i < 100; i++) { + final int k = i; + es.submit(() -> { + try { + start.await(); + String uid = (k % 2 == 0) ? "200" : " 200"; // 포맷 섞기 + long score = System.currentTimeMillis() + k; + tpl.opsForZSet().add(zKey, uid, score); + tpl.opsForHash().put(stKey, uid, "WAITING"); + tpl.opsForHash().put(partyKey, uid, "3"); + tpl.opsForHash().put(numKey, uid, "R-BURST"); + } catch (Exception ignore) { + } finally { + done.countDown(); + } + }); + } + start.countDown(); + done.await(10, TimeUnit.SECONDS); + es.shutdown(); + + // then + var view = dump(); + // 공백/포맷이 다른 멤버가 둘 다 남아있어 2줄 이상일 가능성 + assertThat(view.stream().filter(s -> s.endsWith("|R-BURST")).count()).isGreaterThanOrEqualTo(2); + } +} From c1150f682669ca223cc915c5ac2345e4b21129a3 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Tue, 12 Aug 2025 16:56:39 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor(Menu):=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=A3=BC=EC=A0=90=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20404=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../applicationuser/menu/service/MenuService.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java index 2ab8cfb7..2a5c687f 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java @@ -14,6 +14,8 @@ import com.nowait.domaincorerdb.menu.exception.MenuParamEmptyException; import com.nowait.domaincorerdb.menu.repository.MenuImageRepository; import com.nowait.domaincorerdb.menu.repository.MenuRepository; +import com.nowait.domaincorerdb.store.exception.StoreNotFoundException; +import com.nowait.domaincorerdb.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; @@ -23,6 +25,7 @@ public class MenuService { private final MenuRepository menuRepository; private final MenuImageRepository menuImageRepository; + private final StoreRepository storeRepository; @Transactional(readOnly = true) @@ -30,6 +33,10 @@ public MenuReadResponse getAllMenusByStoreId(Long storeId) { if (storeId == null) { throw new MenuParamEmptyException(); } + + storeRepository.findById(storeId) + .orElseThrow(StoreNotFoundException::new); + List menus = menuRepository.findAllByStoreIdAndDeletedFalseOrderBySortOrder(storeId); List menuReadResponse = menus.stream() @@ -51,6 +58,9 @@ public MenuReadDto getMenuById(Long storeId, Long menuId) { throw new MenuParamEmptyException(); } + storeRepository.findById(storeId) + .orElseThrow(StoreNotFoundException::new); + Menu menu = menuRepository.findByStoreIdAndIdAndDeletedFalse(storeId, menuId) .orElseThrow(MenuNotFoundException::new);