diff --git a/src/main/java/org/patinanetwork/codebloom/api/duel/DuelController.java b/src/main/java/org/patinanetwork/codebloom/api/duel/DuelController.java index 3e27254df..5b934d6af 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/duel/DuelController.java +++ b/src/main/java/org/patinanetwork/codebloom/api/duel/DuelController.java @@ -21,10 +21,10 @@ import org.patinanetwork.codebloom.common.dto.Empty; import org.patinanetwork.codebloom.common.dto.autogen.UnsafeGenericFailureResponse; import org.patinanetwork.codebloom.common.dto.lobby.DuelData; +import org.patinanetwork.codebloom.common.ff.annotation.FF; import org.patinanetwork.codebloom.common.security.AuthenticationObject; import org.patinanetwork.codebloom.common.security.annotation.Protected; import org.patinanetwork.codebloom.common.utils.sse.SseWrapper; -import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; import org.patinanetwork.codebloom.scheduled.pg.handler.LobbyNotifyHandler; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -48,19 +48,16 @@ public class DuelController { private final PartyManager partyManager; private final LobbyRepository lobbyRepository; private final LobbyNotifyHandler lobbyNotifyHandler; - private final FeatureFlagConfiguration ff; public DuelController( final DuelManager duelManager, final PartyManager partyManager, final LobbyRepository lobbyRepository, - final LobbyNotifyHandler lobbyNotifyHandler, - final FeatureFlagConfiguration ff) { + final LobbyNotifyHandler lobbyNotifyHandler) { this.duelManager = duelManager; this.partyManager = partyManager; this.lobbyRepository = lobbyRepository; this.lobbyNotifyHandler = lobbyNotifyHandler; - this.ff = ff; } @Operation(summary = "Join party", description = "Join a party by providing the lobby code.") @@ -91,13 +88,10 @@ public DuelController( @ApiResponse(responseCode = "200", description = "Party has been successfully joined!"), }) @PostMapping("/party/join") + @FF("duels") public ResponseEntity> joinParty( @Protected final AuthenticationObject authenticationObject, @RequestBody final JoinLobbyBody joinPartyBody) { - if (!ff.isDuels()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); - } - joinPartyBody.validate(); var user = authenticationObject.getUser(); @@ -136,11 +130,8 @@ public ResponseEntity> joinParty( @ApiResponse(responseCode = "200", description = "Duel successfully started!"), }) @PostMapping("/start") + @FF("duels") public ResponseEntity> startDuel(@Protected final AuthenticationObject authenticationObject) { - if (!ff.isDuels()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); - } - var user = authenticationObject.getUser(); try { @@ -172,11 +163,8 @@ public ResponseEntity> startDuel(@Protected final Authentica @ApiResponse(responseCode = "200", description = "Party left successfully"), }) @PostMapping("/party/leave") + @FF("duels") public ResponseEntity> leaveParty(@Protected final AuthenticationObject authenticationObject) { - if (!ff.isDuels()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); - } - User user = authenticationObject.getUser(); try { @@ -216,11 +204,8 @@ public ResponseEntity> leaveParty(@Protected final Authentic @ApiResponse(responseCode = "200", description = "Duel has been successfully ended!"), }) @PostMapping("/end") + @FF("duels") public ResponseEntity> endDuel(@Protected final AuthenticationObject authenticationObject) { - if (!ff.isDuels()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); - } - User user = authenticationObject.getUser(); var lobby = lobbyRepository @@ -261,12 +246,9 @@ public ResponseEntity> endDuel(@Protected final Authenticati @ApiResponse(responseCode = "200", description = "Party created successfully"), }) @PostMapping("/party/create") + @FF("duels") public ResponseEntity> createParty( @Protected final AuthenticationObject authenticationObject) { - if (!ff.isDuels()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); - } - User user = authenticationObject.getUser(); String joinCode; @@ -303,11 +285,8 @@ public ResponseEntity> createParty( content = @Content(schema = @Schema(implementation = UnsafeGenericFailureResponse.class))) }) @PostMapping(value = "/{lobbyCode}/sse") + @FF("duels") public SseWrapper> getDuelData(@PathVariable final String lobbyCode) { - if (!ff.isDuels()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); - } - var lobby = lobbyRepository .findActiveLobbyByJoinCode(lobbyCode) .or(() -> lobbyRepository.findAvailableLobbyByJoinCode(lobbyCode)) @@ -347,12 +326,9 @@ public SseWrapper> getDuelData(@PathVariable final String @ApiResponse(responseCode = "200", description = "Party or duel code was successfully found!"), }) @GetMapping("/current") + @FF("duels") public ResponseEntity> getPartyOrDuelCodeForUser( @Protected final AuthenticationObject authenticationObject) { - if (!ff.isDuels()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); - } - var user = authenticationObject.getUser(); Lobby lobby; @@ -393,12 +369,9 @@ public ResponseEntity> getPartyOrDuelCodeForUser( "The user's solved questions were processed (could still mean no new points were awarded)"), }) @PostMapping("/process") + @FF("duels") public ResponseEntity> processSolvedProblemsInDuel( @Protected final AuthenticationObject authenticationObject) { - if (!ff.isDuels()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); - } - var user = authenticationObject.getUser(); Lobby duel; diff --git a/src/main/java/org/patinanetwork/codebloom/api/user/UserController.java b/src/main/java/org/patinanetwork/codebloom/api/user/UserController.java index 1348a2716..4298cf8cc 100644 --- a/src/main/java/org/patinanetwork/codebloom/api/user/UserController.java +++ b/src/main/java/org/patinanetwork/codebloom/api/user/UserController.java @@ -26,10 +26,10 @@ import org.patinanetwork.codebloom.common.dto.question.QuestionDto; import org.patinanetwork.codebloom.common.dto.user.UserDto; import org.patinanetwork.codebloom.common.dto.user.metrics.MetricsDto; +import org.patinanetwork.codebloom.common.ff.annotation.FF; import org.patinanetwork.codebloom.common.lag.FakeLag; import org.patinanetwork.codebloom.common.page.Page; import org.patinanetwork.codebloom.common.time.StandardizedOffsetDateTime; -import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -58,19 +58,16 @@ public class UserController { private final UserRepository userRepository; private final QuestionTopicService questionTopicService; private final UserMetricsRepository userMetricsRepository; - private final FeatureFlagConfiguration ff; public UserController( final QuestionRepository questionRepository, final UserRepository userRepository, final QuestionTopicService questionTopicService, - final UserMetricsRepository userMetricsRepository, - final FeatureFlagConfiguration ff) { + final UserMetricsRepository userMetricsRepository) { this.questionRepository = questionRepository; this.userRepository = userRepository; this.questionTopicService = questionTopicService; this.userMetricsRepository = userMetricsRepository; - this.ff = ff; } @Operation( @@ -227,6 +224,7 @@ public ResponseEntity>> getAllUsers( content = @Content(schema = @Schema(implementation = UnsafeGenericFailureResponse.class))), }) @GetMapping("{userId}/metrics") + @FF("userMetrics") public ResponseEntity>> getUserMetrics( final HttpServletRequest request, @PathVariable final String userId, @@ -244,10 +242,6 @@ public ResponseEntity>> getUserMetrics( FakeLag.sleep(500); - if (!ff.isUserMetrics()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is not available."); - } - if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "startDate cannot be after endDate."); } diff --git a/src/main/java/org/patinanetwork/codebloom/common/ff/FFAspect.java b/src/main/java/org/patinanetwork/codebloom/common/ff/FFAspect.java new file mode 100644 index 000000000..eebdca7ed --- /dev/null +++ b/src/main/java/org/patinanetwork/codebloom/common/ff/FFAspect.java @@ -0,0 +1,53 @@ +package org.patinanetwork.codebloom.common.ff; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.patinanetwork.codebloom.common.ff.annotation.FF; +import org.springframework.context.expression.MapAccessor; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Aspect +@Component +@Slf4j +public class FFAspect { + + private final FeatureFlagManager featureFlagManager; + private final ExpressionParser parser; + + public FFAspect(final FeatureFlagManager featureFlagManager) { + this.featureFlagManager = featureFlagManager; + this.parser = new SpelExpressionParser(); + } + + @Around("@annotation(ff)") + public Object gateMethodByFeatureFlags(final ProceedingJoinPoint joinPoint, final FF ff) throws Throwable { + String expression = ff.value(); + + var flags = featureFlagManager.getAllFlags(); + StandardEvaluationContext context = new StandardEvaluationContext(flags); + context.addPropertyAccessor(new MapAccessor()); + + Boolean isEnabled; + try { + isEnabled = parser.parseExpression(expression).getValue(context, Boolean.class); + } catch (Exception e) { + log.error("Invalid @FF expression: " + expression, e); + throw new IllegalArgumentException("Invalid @FF expression: " + expression, e); + } + + if (!Boolean.TRUE.equals(isEnabled)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Endpoint is not available. Feature flag expression evaluated to false: " + expression); + } + + return joinPoint.proceed(); + } +} diff --git a/src/main/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManager.java b/src/main/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManager.java new file mode 100644 index 000000000..ef4cf950f --- /dev/null +++ b/src/main/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManager.java @@ -0,0 +1,41 @@ +package org.patinanetwork.codebloom.common.ff; + +import java.beans.Introspector; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; +import org.springframework.stereotype.Component; + +@Component +public class FeatureFlagManager { + private final FeatureFlagConfiguration ff; + private final Map cachedFlags; + + public FeatureFlagManager(final FeatureFlagConfiguration ff) { + this.ff = ff; + this.cachedFlags = initializeFlags(); + } + + private Map initializeFlags() { + Map flags = new HashMap<>(); + for (Method method : ff.getClass().getMethods()) { + if (method.getName().startsWith("is") + && method.getReturnType() == boolean.class + && method.getParameterCount() == 0) { + try { + String flagName = Introspector.decapitalize(method.getName().substring(2)); + flags.put(flagName, (Boolean) method.invoke(ff)); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to resolve feature flag value from method: " + method.getName(), e); + } + } + } + return Map.copyOf(flags); + } + + public Map getAllFlags() { + return cachedFlags; + } +} diff --git a/src/main/java/org/patinanetwork/codebloom/common/ff/annotation/FF.java b/src/main/java/org/patinanetwork/codebloom/common/ff/annotation/FF.java new file mode 100644 index 000000000..a3faf44fd --- /dev/null +++ b/src/main/java/org/patinanetwork/codebloom/common/ff/annotation/FF.java @@ -0,0 +1,20 @@ +package org.patinanetwork.codebloom.common.ff.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Feature flag gate + * + *

The value must be a SpEL boolean expression using feature flag names from FeatureFlagManager. Example: + * {@code "duels && userMetrics"} + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface FF { + String value(); +} diff --git a/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java b/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java index d0659943c..a2dc08c4f 100644 --- a/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java +++ b/src/main/java/org/patinanetwork/codebloom/jda/properties/FeatureFlagConfiguration.java @@ -5,6 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +/** Feature flags that we supported. Must be defined through application.yml */ @Getter @Setter @Component diff --git a/src/test/java/org/patinanetwork/codebloom/api/duel/DuelControllerTest.java b/src/test/java/org/patinanetwork/codebloom/api/duel/DuelControllerTest.java index a43618c1f..ea5a4cb5d 100644 --- a/src/test/java/org/patinanetwork/codebloom/api/duel/DuelControllerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/api/duel/DuelControllerTest.java @@ -10,7 +10,6 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -36,7 +35,6 @@ import org.patinanetwork.codebloom.common.time.StandardizedOffsetDateTime; import org.patinanetwork.codebloom.common.utils.duel.PartyCodeGenerator; import org.patinanetwork.codebloom.common.utils.sse.SseWrapper; -import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; import org.patinanetwork.codebloom.scheduled.pg.handler.LobbyNotifyHandler; import org.patinanetwork.codebloom.utilities.exception.ValidationException; import org.springframework.boot.test.context.SpringBootTest; @@ -53,10 +51,9 @@ public class DuelControllerTest { private PartyManager partyManager = mock(PartyManager.class); private LobbyRepository lobbyRepository = mock(LobbyRepository.class); private LobbyNotifyHandler lobbyNotifyHandler = mock(LobbyNotifyHandler.class); - private FeatureFlagConfiguration ff = mock(FeatureFlagConfiguration.class); public DuelControllerTest() { - this.duelController = new DuelController(duelManager, partyManager, lobbyRepository, lobbyNotifyHandler, ff); + this.duelController = new DuelController(duelManager, partyManager, lobbyRepository, lobbyNotifyHandler); this.faker = Faker.instance(); } @@ -82,8 +79,6 @@ private AuthenticationObject createAuthenticationObject(final User user) { @Test @DisplayName("Join lobby - invalid code length") void joinPartyIncorrectLengthCode() { - when(ff.isDuels()).thenReturn(true); - var joinPartyBody = JoinLobbyBody.builder().partyCode("ABC12").build(); User user = createRandomUser(); @@ -99,8 +94,6 @@ void joinPartyIncorrectLengthCode() { @Test @DisplayName("Join lobby - empty code") void joinLobbyEmptyCode() { - when(ff.isDuels()).thenReturn(true); - var joinPartyBody = JoinLobbyBody.builder().partyCode("").build(); User user = createRandomUser(); @@ -116,8 +109,6 @@ void joinLobbyEmptyCode() { @Test @DisplayName("Join lobby - null code") void joinLobbyNullCode() { - when(ff.isDuels()).thenReturn(true); - var joinPartyBody = JoinLobbyBody.builder().partyCode(null).build(); User user = createRandomUser(); @@ -130,34 +121,8 @@ void joinLobbyNullCode() { assertEquals("Lobby code may not be null or empty.", ex.getMessage()); } - @Test - @DisplayName("Join lobby - fails in production environment") - void joinLobbyFailsInProduction() { - when(ff.isDuels()).thenReturn(false); - - var joinPartyBody = JoinLobbyBody.builder().partyCode("ABC123").build(); - - User user = createRandomUser(); - AuthenticationObject authObj = createAuthenticationObject(user); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { - duelController.joinParty(authObj, joinPartyBody); - }); - - assertEquals(HttpStatus.FORBIDDEN.value(), exception.getStatusCode().value()); - assertEquals("Endpoint is currently non-functional", exception.getReason()); - - try { - verify(partyManager, times(0)).joinParty(any(), any()); - } catch (DuelException e) { - fail(e); - } - } - @Test void testJoinPartyPartyManagerFailed() { - when(ff.isDuels()).thenReturn(true); - var joinPartyBody = JoinLobbyBody.builder().partyCode("ABC123").build(); User user = createRandomUser(); @@ -185,8 +150,6 @@ void testJoinPartyPartyManagerFailed() { @Test void testJoinPartyHappyPath() { - when(ff.isDuels()).thenReturn(true); - var joinPartyBody = JoinLobbyBody.builder().partyCode("ABC123").build(); User user = createRandomUser(); @@ -212,30 +175,8 @@ void testJoinPartyHappyPath() { } } - @Test - void testLeavePartyFailureInProductionEnvironment() { - when(ff.isDuels()).thenReturn(false); - - User user = createRandomUser(); - AuthenticationObject authObj = createAuthenticationObject(user); - - org.springframework.web.server.ResponseStatusException exception = assertThrows( - org.springframework.web.server.ResponseStatusException.class, () -> duelController.leaveParty(authObj)); - - assertEquals(403, exception.getStatusCode().value()); - assertEquals("Endpoint is currently non-functional", exception.getReason()); - - try { - verify(partyManager, times(0)).leaveParty(any()); - } catch (DuelException e) { - fail(e); - } - } - @Test void testLeavePartyPartyManagerFailed() { - when(ff.isDuels()).thenReturn(true); - User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); @@ -261,8 +202,6 @@ void testLeavePartyPartyManagerFailed() { @Test void testLeavePartyHappyPath() { - when(ff.isDuels()).thenReturn(true); - User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); @@ -286,31 +225,8 @@ void testLeavePartyHappyPath() { } } - @Test - void testCreatePartyFailsInProductionEnvironment() { - when(ff.isDuels()).thenReturn(false); - - User user = createRandomUser(); - AuthenticationObject authObj = createAuthenticationObject(user); - - org.springframework.web.server.ResponseStatusException exception = assertThrows( - org.springframework.web.server.ResponseStatusException.class, - () -> duelController.createParty(authObj)); - - assertEquals(HttpStatus.FORBIDDEN.value(), exception.getStatusCode().value()); - assertEquals("Endpoint is currently non-functional", exception.getReason()); - - try { - verify(partyManager, times(0)).createParty(any()); - } catch (DuelException e) { - fail(e); - } - } - @Test void testCreatePartyPartyManagerFailed() { - when(ff.isDuels()).thenReturn(true); - User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); @@ -340,8 +256,6 @@ void testCreatePartyPartyManagerFailed() { @Test void testCreatePartyHappyPath() { - when(ff.isDuels()).thenReturn(true); - User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); @@ -367,28 +281,9 @@ void testCreatePartyHappyPath() { } } - @Test - @DisplayName("SSE endpoint - fails in production environment") - void getDuelDataFailsInProduction() { - when(ff.isDuels()).thenReturn(false); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { - duelController.getDuelData(null); - }); - - assertEquals(HttpStatus.FORBIDDEN.value(), exception.getStatusCode().value()); - assertEquals("Endpoint is currently non-functional", exception.getReason()); - - verify(lobbyRepository, times(0)).findActiveLobbyByLobbyPlayerPlayerId(any()); - verify(lobbyRepository, times(0)).findAvailableLobbyByLobbyPlayerPlayerId(any()); - verify(lobbyNotifyHandler, times(0)).register(any(), any()); - } - @Test @DisplayName("SSE endpoint - lobby does not exist") void getDuelDataLobbyDoesNotExist() throws DuelException { - when(ff.isDuels()).thenReturn(true); - when(lobbyRepository.findActiveLobbyByJoinCode(any())).thenReturn(Optional.empty()); when(lobbyRepository.findAvailableLobbyByJoinCode(any())).thenReturn(Optional.empty()); @@ -408,8 +303,6 @@ void getDuelDataLobbyDoesNotExist() throws DuelException { @Test @DisplayName("SSE endpoint - active lobby exists") void getDuelDataPlayerInActiveLobby() { - when(ff.isDuels()).thenReturn(true); - String lobbyId = randomUUID(); Lobby activeLobby = Lobby.builder() .id(lobbyId) @@ -434,8 +327,6 @@ void getDuelDataPlayerInActiveLobby() { @Test @DisplayName("SSE endpoint - available lobby exists") void getDuelDataAvailableLobbyExists() { - when(ff.isDuels()).thenReturn(true); - String lobbyId = randomUUID(); Lobby availableLobby = Lobby.builder() .id(lobbyId) @@ -459,33 +350,11 @@ void getDuelDataAvailableLobbyExists() { verify(lobbyNotifyHandler, times(1)).register(eq(lobbyId), any()); } - @Test - void testStartDuelIsInProd() { - User user = createRandomUser(); - AuthenticationObject authObj = createAuthenticationObject(user); - - when(ff.isDuels()).thenReturn(false); - - ResponseStatusException exception = - assertThrows(ResponseStatusException.class, () -> duelController.startDuel(authObj)); - - assertEquals(403, exception.getStatusCode().value()); - assertEquals("Endpoint is currently non-functional", exception.getReason()); - - try { - verify(duelManager, times(0)).startDuel(any(), eq(false)); - } catch (DuelException e) { - fail(e); - } - } - @Test void testStartDuelDuelManagerFailed() { User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); - when(ff.isDuels()).thenReturn(true); - try { doThrow(new DuelException(HttpStatus.INTERNAL_SERVER_ERROR, "This is an example duel exception.")) .when(duelManager) @@ -511,8 +380,6 @@ void testStartDuelHappyPath() { User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); - when(ff.isDuels()).thenReturn(true); - try { doNothing().when(duelManager).startDuel(eq(user.getId()), eq(user.isAdmin())); } catch (DuelException _) { @@ -526,34 +393,10 @@ void testStartDuelHappyPath() { assertTrue(apiResponder.isSuccess()); } - @Test - void testEndDuelIsInProd() { - User user = createRandomUser(); - AuthenticationObject authObj = createAuthenticationObject(user); - - when(ff.isDuels()).thenReturn(false); - - ResponseStatusException exception = - assertThrows(ResponseStatusException.class, () -> duelController.endDuel(authObj)); - - assertEquals(403, exception.getStatusCode().value()); - assertEquals("Endpoint is currently non-functional", exception.getReason()); - - verify(lobbyRepository, times(0)).findActiveLobbyByLobbyPlayerPlayerId(any()); - - try { - verify(duelManager, times(0)).endDuel(any(), eq(user.isAdmin())); - } catch (DuelException e) { - fail(e); - } - } - @Test void testEndDuelActivePartyForPlayerCannotBeFound() { User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); - - when(ff.isDuels()).thenReturn(true); when(lobbyRepository.findActiveLobbyByLobbyPlayerPlayerId(user.getId())).thenReturn(Optional.empty()); ResponseStatusException exception = @@ -584,7 +427,6 @@ void testEndDuelDuelManagerFailed() { .playerCount(3) .build(); - when(ff.isDuels()).thenReturn(true); when(lobbyRepository.findActiveLobbyByLobbyPlayerPlayerId(user.getId())).thenReturn(Optional.of(lobby)); try { @@ -609,31 +451,8 @@ void testEndDuelDuelManagerFailed() { } } - @Test - void testGetPartyOrDuelCodeForUserFailsInProduction() { - when(ff.isDuels()).thenReturn(false); - - User user = createRandomUser(); - AuthenticationObject authObj = createAuthenticationObject(user); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { - duelController.getPartyOrDuelCodeForUser(authObj); - }); - - assertEquals(HttpStatus.FORBIDDEN.value(), exception.getStatusCode().value()); - assertEquals("Endpoint is currently non-functional", exception.getReason()); - - try { - verify(duelManager, never()).getLobbyByUserId(eq(user.getId())); - } catch (DuelException e) { - fail(e); - } - } - @Test void testGetPartyOrDuelCodeForUserDuelManagerFailed() { - when(ff.isDuels()).thenReturn(true); - User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); @@ -659,8 +478,6 @@ void testGetPartyOrDuelCodeForUserDuelManagerFailed() { @Test void testGetPartyOrDuelCodeForUserHappyPath() { - when(ff.isDuels()).thenReturn(true); - User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); @@ -684,31 +501,8 @@ void testGetPartyOrDuelCodeForUserHappyPath() { } } - @Test - void testProcessSolvedProblemsInDuelFailsInProduction() { - when(ff.isDuels()).thenReturn(false); - - User user = createRandomUser(); - AuthenticationObject authObj = createAuthenticationObject(user); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { - duelController.processSolvedProblemsInDuel(authObj); - }); - - assertEquals(HttpStatus.FORBIDDEN.value(), exception.getStatusCode().value()); - assertEquals("Endpoint is currently non-functional", exception.getReason()); - - try { - verify(duelManager, never()).processSubmissions(eq(user), any()); - } catch (DuelException e) { - fail(e); - } - } - @Test void testProcessSolvedProblemsDuelManagerFailed() { - when(ff.isDuels()).thenReturn(true); - User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); @@ -741,8 +535,6 @@ void testProcessSolvedProblemsDuelManagerFailed() { @Test void testProcessSolvedProblemsHappyPath() { - when(ff.isDuels()).thenReturn(true); - User user = createRandomUser(); AuthenticationObject authObj = createAuthenticationObject(user); diff --git a/src/test/java/org/patinanetwork/codebloom/api/user/UserControllerTest.java b/src/test/java/org/patinanetwork/codebloom/api/user/UserControllerTest.java index e3ffc3590..1500c5919 100644 --- a/src/test/java/org/patinanetwork/codebloom/api/user/UserControllerTest.java +++ b/src/test/java/org/patinanetwork/codebloom/api/user/UserControllerTest.java @@ -35,7 +35,6 @@ import org.patinanetwork.codebloom.common.dto.user.UserDto; import org.patinanetwork.codebloom.common.dto.user.metrics.MetricsDto; import org.patinanetwork.codebloom.common.page.Page; -import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; @@ -48,12 +47,11 @@ public class UserControllerTest { private UserRepository userRepository = mock(UserRepository.class); private QuestionTopicService questionTopicService = mock(QuestionTopicService.class); private UserMetricsRepository userMetricsRepository = mock(UserMetricsRepository.class); - private FeatureFlagConfiguration ff = mock(FeatureFlagConfiguration.class); private HttpServletRequest request = mock(HttpServletRequest.class); public UserControllerTest() { this.userController = - new UserController(questionRepository, userRepository, questionTopicService, userMetricsRepository, ff); + new UserController(questionRepository, userRepository, questionTopicService, userMetricsRepository); this.faker = Faker.instance(); } @@ -374,21 +372,6 @@ private UserMetrics createRandomUserMetrics(final String userId) { .build(); } - @Test - @DisplayName("Get user metrics - feature flag disabled returns 403") - void getUserMetricsFeatureFlagDisabledReturnsForbidden() { - String userId = randomUUID(); - - when(ff.isUserMetrics()).thenReturn(false); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { - userController.getUserMetrics(request, userId, 1, 20, null, null); - }); - - assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); - assertEquals("Endpoint is not available.", exception.getReason()); - } - @Test @DisplayName("Get user metrics - startDate after endDate returns 400") void getUserMetricsInvalidDateRangeReturnsBadRequest() { @@ -396,8 +379,6 @@ void getUserMetricsInvalidDateRangeReturnsBadRequest() { OffsetDateTime startDate = OffsetDateTime.now(); OffsetDateTime endDate = startDate.minusDays(1); - when(ff.isUserMetrics()).thenReturn(true); - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { userController.getUserMetrics(request, userId, 1, 20, startDate, endDate); }); @@ -415,7 +396,6 @@ void getUserMetricsReturnsSuccessfullyWithDateRange() { List metricsList = List.of(createRandomUserMetrics(userId), createRandomUserMetrics(userId)); - when(ff.isUserMetrics()).thenReturn(true); when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(metricsList); when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(2); @@ -443,7 +423,6 @@ void getUserMetricsReturnsSuccessfullyWithDateRange() { void getUserMetricsDefaultsToLastSevenDays() { String userId = randomUUID(); - when(ff.isUserMetrics()).thenReturn(true); when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(List.of()); when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(0); @@ -460,7 +439,6 @@ void getUserMetricsDefaultsToLastSevenDays() { void getUserMetricsEmptyResults() { String userId = randomUUID(); - when(ff.isUserMetrics()).thenReturn(true); when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(List.of()); when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(0); @@ -479,7 +457,6 @@ void getUserMetricsEmptyResults() { void getUserMetricsPageSizeCapped() { String userId = randomUUID(); - when(ff.isUserMetrics()).thenReturn(true); when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(List.of()); when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(0); @@ -501,7 +478,6 @@ void getUserMetricsPaginationMultiplePages() { metricsList.add(createRandomUserMetrics(userId)); } - when(ff.isUserMetrics()).thenReturn(true); when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(metricsList); when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(45); @@ -522,7 +498,6 @@ void getUserMetricsDtoMapsFieldsCorrectly() { String userId = randomUUID(); UserMetrics metric = createRandomUserMetrics(userId); - when(ff.isUserMetrics()).thenReturn(true); when(userMetricsRepository.findUserMetrics(eq(userId), any())).thenReturn(List.of(metric)); when(userMetricsRepository.countUserMetrics(eq(userId), any())).thenReturn(1); diff --git a/src/test/java/org/patinanetwork/codebloom/common/ff/FFAspectTest.java b/src/test/java/org/patinanetwork/codebloom/common/ff/FFAspectTest.java new file mode 100644 index 000000000..8a2a8b741 --- /dev/null +++ b/src/test/java/org/patinanetwork/codebloom/common/ff/FFAspectTest.java @@ -0,0 +1,150 @@ +package org.patinanetwork.codebloom.common.ff; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.patinanetwork.codebloom.common.ff.annotation.FF; +import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.expression.spel.SpelEvaluationException; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@SpringBootTest(classes = FFAspectTest.TestConfig.class) +class FFAspectTest { + + @Autowired + private TestService testService; + + @BeforeEach + void resetCallCount() { + testService.resetCalls(); + } + + @Test + @DisplayName("Allows method execution when expression is true") + void allowsWhenTrue() { + String result = testService.methodWithEnabledFlag(); + + assertEquals("ok", result); + assertEquals(1, testService.getCalls()); + } + + @Test + @DisplayName("Throws forbidden when expression is false") + void forbiddenWhenFalse() { + ResponseStatusException exception = + assertThrows(ResponseStatusException.class, () -> testService.methodWithDisabledFlag()); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + assertEquals(0, testService.getCalls()); + } + + @Test + @DisplayName("Throws when unknown flag is used") + void throwsOnUnknownFlag() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> testService.methodWithUnknownFlag()); + + assertTrue(exception.getMessage().contains("Invalid @FF expression")); + assertTrue(exception.getCause() instanceof SpelEvaluationException); + assertEquals(0, testService.getCalls()); + } + + @Test + @DisplayName("Throws when expression is invalid") + void throwsOnInvalidExpression() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> testService.methodWithInvalidExpression()); + + assertTrue(exception.getMessage().contains("Invalid @FF expression")); + assertEquals(0, testService.getCalls()); + } + + @Test + @DisplayName("Allows literal expressions") + void allowsLiterals() { + String result = testService.methodWithLiteralTrueExpression(); + + assertEquals("ok", result); + assertEquals(1, testService.getCalls()); + } + + public static class TestService { + private int calls; + + @FF("duels") + public String methodWithEnabledFlag() { + calls++; + return "ok"; + } + + @FF("duels && userMetrics") + public String methodWithDisabledFlag() { + calls++; + return "no"; + } + + @FF("duels && school") + public String methodWithUnknownFlag() { + calls++; + return "no"; + } + + @FF("duels &&") + public String methodWithInvalidExpression() { + calls++; + return "no"; + } + + @FF("true") + public String methodWithLiteralTrueExpression() { + calls++; + return "ok"; + } + + public int getCalls() { + return calls; + } + + public void resetCalls() { + calls = 0; + } + } + + @TestConfiguration + @EnableAspectJAutoProxy + static class TestConfig { + + @Bean + public FeatureFlagConfiguration featureFlagConfiguration() { + FeatureFlagConfiguration ff = new FeatureFlagConfiguration(); + ff.setDuels(true); + ff.setUserMetrics(false); + return ff; + } + + @Bean + public FeatureFlagManager featureFlagManager(final FeatureFlagConfiguration ff) { + return new FeatureFlagManager(ff); + } + + @Bean + public FFAspect ffAspect(final FeatureFlagManager featureFlagManager) { + return new FFAspect(featureFlagManager); + } + + @Bean + public TestService testService() { + return new TestService(); + } + } +} diff --git a/src/test/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManagerTest.java b/src/test/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManagerTest.java new file mode 100644 index 000000000..e0cd8b5cf --- /dev/null +++ b/src/test/java/org/patinanetwork/codebloom/common/ff/FeatureFlagManagerTest.java @@ -0,0 +1,39 @@ +package org.patinanetwork.codebloom.common.ff; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration; + +class FeatureFlagManagerTest { + + private FeatureFlagManager featureFlagManager; + + @BeforeEach + void setUp() { + FeatureFlagConfiguration ff = new FeatureFlagConfiguration(); + ff.setDuels(true); + ff.setUserMetrics(false); + featureFlagManager = new FeatureFlagManager(ff); + } + + @Test + void getAllFlagsReturnsValuesFromConfiguration() { + var flags = featureFlagManager.getAllFlags(); + + assertEquals(2, flags.size()); + assertEquals(Boolean.TRUE, flags.get("duels")); + assertEquals(Boolean.FALSE, flags.get("userMetrics")); + } + + @Test + void getAllFlagsReturnsImmutableView() { + var flags = featureFlagManager.getAllFlags(); + + assertThrows(UnsupportedOperationException.class, () -> flags.put("newFlag", true)); + assertFalse(flags.containsKey("newFlag")); + } +} diff --git a/src/test/java/org/patinanetwork/codebloom/config/TestProtector.java b/src/test/java/org/patinanetwork/codebloom/config/TestProtector.java index 844695df6..ec9d2b28c 100644 --- a/src/test/java/org/patinanetwork/codebloom/config/TestProtector.java +++ b/src/test/java/org/patinanetwork/codebloom/config/TestProtector.java @@ -1,6 +1,7 @@ package org.patinanetwork.codebloom.config; import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; import org.patinanetwork.codebloom.common.db.models.Session; import org.patinanetwork.codebloom.common.db.models.user.User; import org.patinanetwork.codebloom.common.db.repos.session.SessionRepository; @@ -37,9 +38,10 @@ public Protector protector() { @Override public AuthenticationObject validateSession(final HttpServletRequest request) { User mockAdminUser = userRepository.getUserById("ed3bfe18-e42a-467f-b4fa-07e8da4d2555"); - Session mockAdminSession = sesssionRepository - .getSessionById("d99e10a2-6285-46f0-8150-ba4727b520f4") - .orElseThrow(); + Optional mockAdminSessionOp = + sesssionRepository.getSessionById("d99e10a2-6285-46f0-8150-ba4727b520f4"); + + Session mockAdminSession = mockAdminSessionOp.isPresent() ? mockAdminSessionOp.get() : null; return new AuthenticationObject(mockAdminUser, mockAdminSession); }