diff --git a/.github/workflows/develop-ci.yml b/.github/workflows/develop-ci.yml index 5cbd3dcd..44812cba 100644 --- a/.github/workflows/develop-ci.yml +++ b/.github/workflows/develop-ci.yml @@ -5,7 +5,7 @@ on: - main - 'feature/**' pull_request: - types: [ opened, reopened, edited ] + types: [ opened, reopened, edited, synchronize ] jobs: build: name: Build and analyze diff --git a/src/main/java/com/run_us/server/RunUsApplication.java b/src/main/java/com/run_us/server/RunUsApplication.java index 78f7f03a..b80f13bd 100644 --- a/src/main/java/com/run_us/server/RunUsApplication.java +++ b/src/main/java/com/run_us/server/RunUsApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class RunUsApplication { diff --git a/src/main/java/com/run_us/server/domains/crew/domain/Crew.java b/src/main/java/com/run_us/server/domains/crew/domain/Crew.java index f286bb87..2e8eccec 100644 --- a/src/main/java/com/run_us/server/domains/crew/domain/Crew.java +++ b/src/main/java/com/run_us/server/domains/crew/domain/Crew.java @@ -23,6 +23,7 @@ public class Crew extends DateAudit { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "crew_id") private Integer id; @Column(name = "public_id", nullable = false, columnDefinition = "CHAR(13)") diff --git a/src/main/java/com/run_us/server/domains/running/live/controller/RunningSocketController.java b/src/main/java/com/run_us/server/domains/running/live/controller/RunningSocketController.java index 19988495..77c0cf30 100644 --- a/src/main/java/com/run_us/server/domains/running/live/controller/RunningSocketController.java +++ b/src/main/java/com/run_us/server/domains/running/live/controller/RunningSocketController.java @@ -7,6 +7,7 @@ import com.run_us.server.domains.running.live.controller.model.RunningSocketResponseCode; import com.run_us.server.domains.running.live.service.RunningLiveService; import com.run_us.server.domains.running.common.RunningConst; +import com.run_us.server.domains.running.live.service.RunningWebsocketService; import com.run_us.server.global.common.SuccessResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,7 +23,7 @@ public class RunningSocketController { private final SimpMessagingTemplate simpMessagingTemplate; private final RunningLiveService runningLiveService; - + private final RunningWebsocketService runningWebsocketService; /** * 러닝 시작 * @param userId 사용자 (고유번호 세션에서 추출) @@ -32,7 +33,7 @@ public class RunningSocketController { public void startRunning(@UserId String userId, RunningSocketRequest.StartRunning requestDto) { log.info("action=start_running user_id={} running_id={}", userId, requestDto.getRunningPublicId()); SuccessResponse response = - runningLiveService.startRunning(requestDto.getRunningPublicId(), userId, requestDto.getCount()); + runningWebsocketService.startRunningSession(requestDto.getRunningPublicId(), requestDto.getCount()); simpMessagingTemplate.convertAndSend( RunningConst.RUNNING_WS_SEND_PREFIX + requestDto.getRunningPublicId(), response); } @@ -45,13 +46,12 @@ public void startRunning(@UserId String userId, RunningSocketRequest.StartRunnin @MessageMapping("/users/runnings/location") public void updateLocation(@UserId String userId, RunningSocketRequest.LocationUpdate requestDto) { log.info("action=update_running user_id={} running_id={}", userId, requestDto.getRunningPublicId()); - SuccessResponse response = runningLiveService.updateLocation( + runningLiveService.updateLocation( requestDto.getRunningPublicId(), userId, requestDto.getLatitude(), requestDto.getLongitude(), requestDto.getCount()); - simpMessagingTemplate.convertAndSend(RunningConst.RUNNING_WS_SEND_PREFIX + requestDto.getRunningPublicId(), response); } /*** diff --git a/src/main/java/com/run_us/server/domains/running/live/repository/RunningRedisRepository.java b/src/main/java/com/run_us/server/domains/running/live/repository/RunningRedisRepository.java index 3553c6d0..a6d8d4ef 100644 --- a/src/main/java/com/run_us/server/domains/running/live/repository/RunningRedisRepository.java +++ b/src/main/java/com/run_us/server/domains/running/live/repository/RunningRedisRepository.java @@ -7,24 +7,25 @@ import com.run_us.server.domains.running.live.service.model.LocationData; import com.run_us.server.domains.running.live.service.model.ParticipantStatus; import com.run_us.server.domains.running.common.RunningConst; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; + +import java.io.Serializable; +import java.util.*; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Repository; +@Slf4j @Repository +@RequiredArgsConstructor public class RunningRedisRepository { private final RedisTemplate redisTemplate; + private final RedisTemplate serializableRedisTemplate; private final ObjectMapper objectMapper; - - public RunningRedisRepository( - RedisTemplate redisTemplate, ObjectMapper objectMapper) { - this.redisTemplate = redisTemplate; - this.objectMapper = objectMapper; - } + private final DefaultRedisScript updateLocationScript; /*** * 러닝세션 참가자의 상태를 업데이트 @@ -60,20 +61,16 @@ public ParticipantStatus getParticipantStatus(String runningId, String userId) { public void updateParticipantLocation( String runningId, String userId, double latitude, double longitude, long count) { String key = createLiveKey(runningId, userId, RunningConst.LOCATION_SUFFIX); - String currentValue = redisTemplate.opsForValue().get(key); - try { - if (currentValue != null) { - LocationData current = objectMapper.readValue(currentValue, LocationData.class); - if (count <= current.getCount()) { - return; // 기존 정보보다 과거 정보라면 업데이트 하지 않고 폐기 - } - } - - LocationData newLocation = new LocationData(latitude, longitude, count); - String newValue = objectMapper.writeValueAsString(newLocation); - redisTemplate.opsForValue().set(key, newValue); + serializableRedisTemplate.execute( + updateLocationScript, + List.of(key), + latitude, + longitude, + 10, + count); } catch (Exception e) { + log.error("Failed to update location", e); throw new RuntimeException("Failed to update location", e); } } @@ -133,7 +130,7 @@ public Set getSessionParticipants(String runningId) { * @param runningId 러닝세션 외부 노출용 ID * @return 러닝세션 참가자 전체의 위치정보 목록 */ - private Map getAllParticipantsLocations(String runningId) { + public Map getAllParticipantsLocations(String runningId) { Map participantsLocations = new HashMap<>(); String pattern = createLiveKey(runningId, "*", RunningConst.LOCATION_SUFFIX); Set keys = redisTemplate.keys(pattern); diff --git a/src/main/java/com/run_us/server/domains/running/live/service/RunningLiveService.java b/src/main/java/com/run_us/server/domains/running/live/service/RunningLiveService.java index 45b2be14..774a6e5e 100644 --- a/src/main/java/com/run_us/server/domains/running/live/service/RunningLiveService.java +++ b/src/main/java/com/run_us/server/domains/running/live/service/RunningLiveService.java @@ -12,19 +12,14 @@ import com.run_us.server.domains.running.common.RunningConst; import com.run_us.server.domains.running.run.domain.Run; import com.run_us.server.domains.running.run.service.ParticipantService; -import com.run_us.server.domains.running.run.service.RunCommandService; import com.run_us.server.domains.running.run.service.RunQueryService; import com.run_us.server.domains.user.domain.User; import com.run_us.server.global.common.SuccessResponse; import jakarta.transaction.Transactional; import java.util.Collections; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; + + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -33,12 +28,9 @@ public class RunningLiveService { private final RunningRedisRepository runningRedisRepository; - private final RunCommandService runCommandService; private final RunQueryService runQueryService; private final ParticipantService participantService; private final UpdateLocationRepository locationRepository; - private final Map sessionSchedulers = new ConcurrentHashMap<>(); - /*** * 러닝세션 참가: 참가자 상태를 READY로 변경 * @param runningId 러닝세션 외부 노출용 ID @@ -110,40 +102,7 @@ public List endRunning(String runningId, String userId) return Collections.emptyList(); } - /*** - * 러닝세션 시작: 전체 참가자 상태를 RUN으로 변경하고, 위치 업데이트 스케줄러 시작 - * @param runningId 러닝세션 외부 노출용 ID - */ - public void startRunningSession(String runningId, long count) { - ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - scheduler.scheduleAtFixedRate( - () -> runningRedisRepository.publishLocationUpdatesAll(runningId), - 0, - RunningConst.UPDATE_INTERVAL, - TimeUnit.MILLISECONDS); - sessionSchedulers.put(runningId, scheduler); - - Set participants = runningRedisRepository.getSessionParticipants(runningId); - for (String userId : participants) { - startRunning(runningId, userId, count); - } - } - - /*** - * 러닝세션 종료: 전체 참가자 상태를 END로 변경하고, 위치 업데이트 스케줄러 종료 - * @param runningId 러닝세션 외부 노출용 ID - */ - public void finishRunningSession(String runningId) { - ScheduledExecutorService scheduler = sessionSchedulers.remove(runningId); - if (scheduler != null) { - scheduler.shutdown(); - } - Set participants = runningRedisRepository.getSessionParticipants(runningId); - for (String userId : participants) { - endRunning(runningId, userId); - } - } /*** * 러닝세션 참가자 위치 업데이트: 참가자의 위치를 업데이트하고, 이동 거리가 일정 이상일 경우 즉시 publish @@ -153,21 +112,9 @@ public void finishRunningSession(String runningId) { * @param longitude 경도 * @param count 송신 횟수 */ - public SuccessResponse updateLocation( + public void updateLocation( String runningId, String userId, float latitude, float longitude, long count) { runningRedisRepository.updateParticipantLocation(runningId, userId, latitude, longitude, count); - - LocationData.RunnerPos lastLocation = - runningRedisRepository.getParticipantLocation(runningId, userId); - LocationData.RunnerPos newLocation = new LocationData.RunnerPos(latitude, longitude); - locationRepository.saveLocation(createLiveKey(runningId, userId, RUNNING_PREFIX), newLocation); - - if (lastLocation != null && isSignificantMove(lastLocation, newLocation)) { - runningRedisRepository.publishLocationUpdateSingle(runningId, userId, latitude, longitude); - } - return SuccessResponse.of( - RunningSocketResponseCode.UPDATE_LOCATION, - new RunningSocketResponse.LocationUpdate(userId, latitude, longitude, count)); } /*** diff --git a/src/main/java/com/run_us/server/domains/running/live/service/RunningWebsocketService.java b/src/main/java/com/run_us/server/domains/running/live/service/RunningWebsocketService.java new file mode 100644 index 00000000..9a864335 --- /dev/null +++ b/src/main/java/com/run_us/server/domains/running/live/service/RunningWebsocketService.java @@ -0,0 +1,87 @@ +package com.run_us.server.domains.running.live.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.run_us.server.domains.running.common.RunningConst; +import com.run_us.server.domains.running.live.controller.model.RunningSocketResponse; +import com.run_us.server.domains.running.live.controller.model.RunningSocketResponseCode; +import com.run_us.server.domains.running.live.repository.RunningRedisRepository; +import com.run_us.server.domains.running.live.service.model.LocationData; + +import com.run_us.server.global.common.SuccessResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RunningWebsocketService { + + private final RunningLiveService runningLiveService; + private final RunningRedisRepository runningRedisRepository; + private final SimpMessagingTemplate simpMessagingTemplate; + private final ObjectMapper objectMapper; + private final Map sessionSchedulers = new ConcurrentHashMap<>(); + + /*** + * 러닝세션 시작: 전체 참가자 상태를 RUN으로 변경하고, 위치 업데이트 스케줄러 시작 + * @param runningId 러닝세션 외부 노출용 ID + */ + public SuccessResponse startRunningSession(String runningId, long count) { + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + scheduler.scheduleAtFixedRate( + new BroadcastPositionsTask(runningId), + 0, + RunningConst.UPDATE_INTERVAL, + TimeUnit.MILLISECONDS); + sessionSchedulers.put(runningId, scheduler); + return SuccessResponse.of( + RunningSocketResponseCode.START_RUNNING, + new RunningSocketResponse.StartRunning(runningId, count)); + } + + /*** + * 러닝세션 종료: 전체 참가자 상태를 END로 변경하고, 위치 업데이트 스케줄러 종료 + * @param runningId 러닝세션 외부 노출용 ID + */ + public void finishRunningSession(String runningId) { + ScheduledExecutorService scheduler = sessionSchedulers.remove(runningId); + if (scheduler != null) { + scheduler.shutdown(); + } + + Set participants = runningRedisRepository.getSessionParticipants(runningId); + for (String userId : participants) { + runningLiveService.endRunning(runningId, userId); + } + } + + private class BroadcastPositionsTask implements Runnable { + + private String runningId; + + public BroadcastPositionsTask(String runningId) { + this.runningId = runningId; + } + + @Override + public void run() { + Map participantsLocations = runningRedisRepository.getAllParticipantsLocations(runningId); + try { + String message = objectMapper.writeValueAsString(participantsLocations); + simpMessagingTemplate.convertAndSend(RunningConst.RUNNING_WS_SEND_PREFIX + runningId, message); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/com/run_us/server/global/config/RedisConfig.java b/src/main/java/com/run_us/server/global/config/RedisConfig.java index 43669378..83fadf7d 100644 --- a/src/main/java/com/run_us/server/global/config/RedisConfig.java +++ b/src/main/java/com/run_us/server/global/config/RedisConfig.java @@ -1,15 +1,29 @@ package com.run_us.server.global.config; +import io.lettuce.core.ClientOptions; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericToStringSerializer; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.scripting.ScriptSource; +import org.springframework.scripting.support.ResourceScriptSource; + +import java.io.IOException; +import java.io.Serializable; +import java.time.Duration; @Configuration @EnableRedisRepositories @@ -22,12 +36,23 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private int redisPort; + @Value("${spring.data.redis.password}") + private String redisPassword; + @Bean public RedisConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); redisStandaloneConfiguration.setHostName(redisHost); redisStandaloneConfiguration.setPort(redisPort); - return new LettuceConnectionFactory(redisStandaloneConfiguration); + redisStandaloneConfiguration.setPassword(redisPassword); + ClientOptions clientOptions = ClientOptions.builder() + .pingBeforeActivateConnection(true) + .autoReconnect(true) + .build(); + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .clientOptions(clientOptions) + .build(); + return new LettuceConnectionFactory(redisStandaloneConfiguration, clientConfig); } @Bean @@ -38,4 +63,19 @@ public RedisTemplate redisTemplate() { redisTemplate.setValueSerializer(new StringRedisSerializer()); return redisTemplate; } + + @Bean + public RedisTemplate serializableRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(String.class)); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } + + @Bean + public DefaultRedisScript updateLocationScript() throws IOException { + ScriptSource source = new ResourceScriptSource(new ClassPathResource("META-INF/scripts/update_location.lua")); + return new DefaultRedisScript<>(source.getScriptAsString(), Boolean.class); + } } \ No newline at end of file diff --git a/src/main/resources/META-INF/scripts/update_location.lua b/src/main/resources/META-INF/scripts/update_location.lua new file mode 100644 index 00000000..e0ea08ec --- /dev/null +++ b/src/main/resources/META-INF/scripts/update_location.lua @@ -0,0 +1,24 @@ +-- Lua Script: Compare and Update Location if Count is Greater +-- KEYS[1]: Redis key (location identifier) +-- ARGV[1]: Latitude +-- ARGV[2]: Longitude +-- ARGV[3]: TTL (Time-to-Live in seconds) +-- ARGV[4]: Count + +local current = redis.call('GET', KEYS[1]) + +if current then + local currentData = cjson.decode(current) + if tonumber(ARGV[4]) <= currentData.count then + return false -- Do not update if the current count is greater or equal + end +end + +local newData = cjson.encode({ + latitude = tonumber(ARGV[1]), + longitude = tonumber(ARGV[2]), + count = tonumber(ARGV[4]) +}) + +redis.call('SET', KEYS[1], newData, 'EX', ARGV[3]) +return true -- Successfully updated diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index baee2d68..e6268008 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,11 @@ server: port: 8080 - + tomcat: + threads: + max: 200 + accept-count: 100 + max-connections: 8912 + max-keep-alive-requests: 200 spring: config: import: @@ -17,7 +22,6 @@ spring: properties: hibernate: dialect: ${JPA_DIALECT} - format_sql: true open-in-view: false jackson: property-naming-strategy: SNAKE_CASE diff --git a/src/test/java/com/run_us/server/RunUsApplicationTests.java b/src/test/java/com/run_us/server/RunUsApplicationTests.java index 0e536925..30735234 100644 --- a/src/test/java/com/run_us/server/RunUsApplicationTests.java +++ b/src/test/java/com/run_us/server/RunUsApplicationTests.java @@ -1,9 +1,14 @@ package com.run_us.server; +import com.run_us.server.config.TestRedisConfiguration; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@Import({TestRedisConfiguration.class}) +@ActiveProfiles("test") class RunUsApplicationTests { @Test diff --git a/src/test/java/com/run_us/server/config/TestRedisConfiguration.java b/src/test/java/com/run_us/server/config/TestRedisConfiguration.java index 42a106c2..432d712b 100644 --- a/src/test/java/com/run_us/server/config/TestRedisConfiguration.java +++ b/src/test/java/com/run_us/server/config/TestRedisConfiguration.java @@ -5,12 +5,20 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.scripting.ScriptSource; +import org.springframework.scripting.support.ResourceScriptSource; + +import java.io.IOException; +import java.io.Serializable; @TestConfiguration @Profile("test") @@ -46,4 +54,19 @@ public RedisTemplate redisTemplate() { redisTemplate.setValueSerializer(new StringRedisSerializer()); return redisTemplate; } + + @Bean + public RedisTemplate serializableRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericToStringSerializer<>(String.class)); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } + + @Bean + public DefaultRedisScript updateLocationScript() throws IOException { + ScriptSource source = new ResourceScriptSource(new ClassPathResource("META-INF/scripts/update_location.lua")); + return new DefaultRedisScript<>(source.getScriptAsString(), Boolean.class); + } } diff --git a/src/test/java/com/run_us/server/domains/crew/service/CrewValidatorTest.java b/src/test/java/com/run_us/server/domains/crew/service/CrewValidatorTest.java index 00e2ad50..f2f9475a 100644 --- a/src/test/java/com/run_us/server/domains/crew/service/CrewValidatorTest.java +++ b/src/test/java/com/run_us/server/domains/crew/service/CrewValidatorTest.java @@ -6,28 +6,33 @@ import com.run_us.server.domains.crew.domain.CrewJoinRequest; import com.run_us.server.domains.crew.domain.enums.CrewJoinRequestStatus; import com.run_us.server.domains.crew.repository.CrewJoinRequestRepository; +import com.run_us.server.domains.crew.repository.CrewRepository; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.context.ActiveProfiles; import java.time.ZonedDateTime; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -@SpringBootTest @ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) class CrewValidatorTest { - @MockBean + @Mock + private CrewRepository crewRepository; + @Mock private CrewJoinRequestRepository crewJoinRequestRepository; - @Autowired + @InjectMocks private CrewValidator crewValidator; @DisplayName("한달 이내에 제출한 가입신청이 아직 처리되지 않은 경우 에러 반환") @@ -45,6 +50,7 @@ void should_fail_when_request_within_one_month_not_reviewed() { .processedAt(null) .build()) ); + when(crewRepository.existsMembershipByCrewIdAndUserId(any(), any())).thenReturn(false); assertThrows(CrewException.class, () -> crewValidator.validateCanJoinCrew(userId, crew)); } @@ -63,6 +69,8 @@ void should_fail_when_request_within_one_month_rejected() { .processedAt(ZonedDateTime.now()) .build()) ); + when(crewRepository.existsMembershipByCrewIdAndUserId(any(), any())).thenReturn(false); + assertThrows(CrewException.class, () -> crewValidator.validateCanJoinCrew(userId, crew)); } } \ No newline at end of file