diff --git a/pom.xml b/pom.xml
index 62d686a..8d515db 100644
--- a/pom.xml
+++ b/pom.xml
@@ -128,11 +128,6 @@
postgresql
runtime
-
- org.projectlombok
- lombok
- true
-
org.mapstruct
mapstruct
@@ -204,10 +199,6 @@
org.springframework.boot
spring-boot-configuration-processor
-
- org.projectlombok
- lombok
-
org.mapstruct
mapstruct-processor
@@ -255,14 +246,6 @@
org.springframework.boot
spring-boot-maven-plugin
-
-
-
- org.projectlombok
- lombok
-
-
-
diff --git a/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java b/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java
index 5796f3b..76b6b65 100644
--- a/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java
+++ b/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java
@@ -4,6 +4,7 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
+/// Main application
@SpringBootApplication
@EnableScheduling
public class OpenPodcastAPI {
diff --git a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java
index 64d1b8d..0482bde 100644
--- a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java
+++ b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java
@@ -1,20 +1,19 @@
package org.openpodcastapi.opa.advice;
import jakarta.persistence.EntityNotFoundException;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.exceptions.ValidationErrorResponse;
+import org.slf4j.Logger;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
-import java.util.List;
+
+import static org.slf4j.LoggerFactory.getLogger;
/// A global handler for common exceptions thrown by the application.
///
@@ -22,51 +21,49 @@
/// However, for common exceptions such as invalid parameters and
/// not found entities, a global exception handler can be added.
@RestControllerAdvice
-@RequiredArgsConstructor
-@Log4j2
public class GlobalExceptionHandler {
+
+ private static final Logger log = getLogger(GlobalExceptionHandler.class);
+
/// Returns a 404 if a database entity is not found
///
- /// @param exception the thrown [EntityNotFoundException]
- /// @return a [ResponseEntity] containing the error message
+ /// @param exception the thrown exception
+ /// @return a response containing the error message
@ExceptionHandler(EntityNotFoundException.class)
- @ResponseStatus(HttpStatus.NOT_FOUND)
public ResponseEntity<@NonNull String> handleEntityNotFoundException(EntityNotFoundException exception) {
- log.debug("{}", exception.getMessage());
+ log.info("{}", exception.getMessage());
return ResponseEntity.notFound().build();
}
/// Returns a 400 error when conflicting data is entered
///
- /// @param exception the thrown [DataIntegrityViolationException]
- /// @return a [ResponseEntity] containing the error message
+ /// @param exception the thrown exception
+ /// @return a response containing the error message
@ExceptionHandler(DataIntegrityViolationException.class)
- @ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<@NonNull String> handleDataIntegrityViolationException(DataIntegrityViolationException exception) {
return ResponseEntity.badRequest().body(exception.getMessage());
}
/// Returns a 400 error when illegal arguments are passed
///
- /// @param exception the thrown [IllegalArgumentException]
- /// @return a [ResponseEntity] containing the error message
+ /// @param exception the thrown exception
+ /// @return a response containing the error message
@ExceptionHandler(IllegalArgumentException.class)
- @ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<@NonNull String> handleIllegalArgumentException(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(exception.getMessage());
}
/// Returns a 400 error when invalid arguments are passed to an endpoint
///
- /// @param exception the thrown [MethodArgumentNotValidException]
- /// @return a [ResponseEntity] containing the error message
+ /// @param exception the thrown exception
+ /// @return a response containing the error message
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<@NonNull ValidationErrorResponse> handleValidationException(MethodArgumentNotValidException exception) {
- List errors = exception.getBindingResult().getFieldErrors().stream()
+ final var errors = exception.getBindingResult().getFieldErrors().stream()
.map(fe -> new ValidationErrorResponse.FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();
- var body = new ValidationErrorResponse(
+ final var body = new ValidationErrorResponse(
Instant.now(),
HttpStatus.BAD_REQUEST.value(),
errors
diff --git a/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java b/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java
index 221b670..fdfdc50 100644
--- a/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java
+++ b/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java
@@ -1,6 +1,6 @@
package org.openpodcastapi.opa.advice;
-import lombok.extern.log4j.Log4j2;
+import org.slf4j.Logger;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.ui.Model;
@@ -9,34 +9,40 @@
import java.security.Principal;
+import static org.slf4j.LoggerFactory.getLogger;
+
/// A helper class for adding user information to requests.
///
/// This class is used to populate user details in templates
/// and to ensure that a user is authenticated when viewing
/// web pages.
-@Log4j2
@ControllerAdvice
public class GlobalModelAttributeAdvice {
+ private static final Logger log = getLogger(GlobalModelAttributeAdvice.class);
+
/// Adds a boolean `isAuthenticated` property to the request model based on
/// whether the user is logged-in.
///
- /// @param model the [Model] attached to the request
+ /// @param model the variables attached to the request
@ModelAttribute
public void addAuthenticationFlag(Model model) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
- var isAuthenticated = authentication != null && authentication.isAuthenticated()
+ final var isAuthenticated = authentication != null && authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal());
+ assert authentication != null;
+ log.debug("Authentication flag for {} added", authentication.getPrincipal());
model.addAttribute("isAuthenticated", isAuthenticated);
}
/// Adds user details to the request model.
///
- /// @param principal the [Principal] representing the user
- /// @param model the [Model] attached to the request
+ /// @param principal the principal representing the user
+ /// @param model the variables attached to the request
@ModelAttribute
public void addUserDetails(Principal principal, Model model) {
- var username = principal != null ? principal.getName() : "Guest";
+ final var username = principal != null ? principal.getName() : "Guest";
+ log.debug("User details for {} added to model", username);
model.addAttribute("username", username);
}
}
\ No newline at end of file
diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java b/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java
index e30e5b1..92cfdb3 100644
--- a/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java
+++ b/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java
@@ -1,11 +1,14 @@
package org.openpodcastapi.opa.auth;
import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationConverter;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.stereotype.Component;
+import static org.slf4j.LoggerFactory.getLogger;
+
/// A converter that handles JWT-based auth for API requests.
///
/// This converter targets only the API endpoints at `/api`.
@@ -13,6 +16,8 @@
@Component
public class ApiBearerTokenAuthenticationConverter implements AuthenticationConverter {
+ private static final Logger log = getLogger(ApiBearerTokenAuthenticationConverter.class);
+
private final BearerTokenAuthenticationConverter delegate =
new BearerTokenAuthenticationConverter();
@@ -23,15 +28,18 @@ public Authentication convert(HttpServletRequest request) {
// Don't authenticate the auth endpoints
if (path.startsWith("/api/auth/")) {
+ log.debug("Bypassing token check for auth endpoint");
return null;
}
// If the request has no Bearer token, return null
final var header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
+ log.debug("Request with no auth header sent to {}", request.getRequestURI());
return null;
}
+ log.debug("Converting request");
// Task Spring Boot with handling the request
return delegate.convert(request);
}
diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java
index 5a5b3b4..759864d 100644
--- a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java
+++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java
@@ -2,7 +2,7 @@
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
-import lombok.NonNull;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.service.CustomUserDetails;
import org.openpodcastapi.opa.user.UserRepository;
import org.springframework.beans.factory.annotation.Value;
@@ -28,7 +28,7 @@ public class JwtAuthenticationProvider implements AuthenticationProvider {
/// Constructor with secret value provided in `.env` file
/// or environment variables.
///
- /// @param repository the [UserRepository] interface for user entities
+ /// @param repository the repository interface for user entities
/// @param secret the secret value used to generate JWT values
public JwtAuthenticationProvider(
UserRepository repository,
diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java
index 32a6ce8..cc89dca 100644
--- a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java
+++ b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java
@@ -1,6 +1,5 @@
package org.openpodcastapi.opa.config;
-import lombok.RequiredArgsConstructor;
import org.openpodcastapi.opa.auth.ApiBearerTokenAuthenticationConverter;
import org.openpodcastapi.opa.auth.JwtAuthenticationProvider;
import org.springframework.context.annotation.Bean;
@@ -25,7 +24,6 @@
/// Security configuration for the Spring application
@Configuration
@EnableWebSecurity
-@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {
@@ -44,12 +42,12 @@ public class SecurityConfig {
/// API-related security configuration
///
- /// @param http the [HttpSecurity] object to be configured
- /// @param jwtAuthenticationProvider the [JwtAuthenticationProvider] used to handle JWT auth
+ /// @param http the security object to be configured
+ /// @param jwtAuthenticationProvider the JWT provider used to handle JWT auth
/// @param entryPoint the entrypoint that commences the JWT auth
- /// @param deniedHandler the [AccessDeniedHandler] that handles auth failures
- /// @param converter the [ApiBearerTokenAuthenticationConverter] that manages JWT validation
- /// @return the configured [HttpSecurity] object
+ /// @param deniedHandler the handler that handles auth failures
+ /// @param converter the bearer token converter that manages JWT validation
+ /// @return the configured security object
@Bean
@Order(1)
public SecurityFilterChain apiSecurity(
@@ -60,9 +58,9 @@ public SecurityFilterChain apiSecurity(
ApiBearerTokenAuthenticationConverter converter
) {
- AuthenticationManager jwtManager = new ProviderManager(jwtAuthenticationProvider);
+ final var jwtManager = new ProviderManager(jwtAuthenticationProvider);
- BearerTokenAuthenticationFilter bearerFilter =
+ final var bearerFilter =
new BearerTokenAuthenticationFilter(jwtManager, converter);
bearerFilter.setAuthenticationFailureHandler(
@@ -90,8 +88,8 @@ public SecurityFilterChain apiSecurity(
/// Web-related security configuration
///
- /// @param http the [HttpSecurity] object to be configured
- /// @return the configured [HttpSecurity] object
+ /// @param http the security object to be configured
+ /// @return the configured security object
@Bean
@Order(2)
public SecurityFilterChain webSecurity(HttpSecurity http) {
@@ -119,7 +117,7 @@ public SecurityFilterChain webSecurity(HttpSecurity http) {
/// The default password encoder used for hashing and encoding user passwords and JWTs
///
- /// @return a configured [BCryptPasswordEncoder]
+ /// @return a configured password encoder
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
@@ -127,21 +125,21 @@ public BCryptPasswordEncoder passwordEncoder() {
/// An authentication provider for password-based authentication
///
- /// @param userDetailsService the [UserDetailsService] for loading user data
+ /// @param userDetailsService the service for loading user data
/// @param passwordEncoder the default password encoder
- /// @return the configured [DaoAuthenticationProvider]
+ /// @return the configured authentication provider
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService,
BCryptPasswordEncoder passwordEncoder) {
- DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
+ final var provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
/// An authentication provider for JWT-based authentication
///
- /// @param provider a configured [JwtAuthenticationProvider]
- /// @return a configured [ProviderManager] that uses the JWT auth provider
+ /// @param provider a configured provider
+ /// @return a configured manager that uses the JWT auth provider
/// @see JwtAuthenticationProvider for provider details
@Bean(name = "jwtAuthManager")
public AuthenticationManager jwtAuthenticationManager(JwtAuthenticationProvider provider) {
@@ -150,8 +148,8 @@ public AuthenticationManager jwtAuthenticationManager(JwtAuthenticationProvider
/// An authentication provider for API POST login
///
- /// @param daoProvider a configured [DaoAuthenticationProvider]
- /// @return a configured [ProviderManager] that uses basic username/password auth
+ /// @param daoProvider a configured auth provider
+ /// @return a configured manager that uses basic username/password auth
@Bean(name = "apiLoginManager", defaultCandidate = false)
public AuthenticationManager apiLoginAuthenticationManager(
DaoAuthenticationProvider daoProvider) {
diff --git a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java
index 1c41eef..604a96c 100644
--- a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java
+++ b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java
@@ -31,7 +31,7 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
///
/// See [Thymeleaf Layout Dialect](https://ultraq.github.io/thymeleaf-layout-dialect/) for more information
///
- /// @return the configured [LayoutDialect]
+ /// @return the configured layout dialect
@Bean
public LayoutDialect layoutDialect() {
return new LayoutDialect();
diff --git a/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java
index daec4f1..8d31de3 100644
--- a/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java
+++ b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java
@@ -2,17 +2,14 @@
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.constraints.NotNull;
-import lombok.NonNull;
-import lombok.extern.log4j.Log4j2;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.auth.AuthDTO;
import org.openpodcastapi.opa.security.TokenService;
-import org.openpodcastapi.opa.user.UserEntity;
import org.openpodcastapi.opa.user.UserRepository;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
-import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -20,7 +17,6 @@
/// Controllers for API-based authentication
@RestController
-@Log4j2
public class AuthController {
private final TokenService tokenService;
private final UserRepository userRepository;
@@ -44,12 +40,12 @@ public AuthController(
/// The API login endpoint. Accepts a basic username/password combination to authenticate.
///
- /// @param loginRequest the [AuthDTO.LoginRequest] containing the user's credentials
- /// @return a [ResponseEntity] containing a [AuthDTO.LoginSuccessResponse]
+ /// @param loginRequest the login request containing the user's credentials
+ /// @return a success response
@PostMapping("/api/auth/login")
public ResponseEntity login(@RequestBody @NotNull AuthDTO.LoginRequest loginRequest) {
// Set the authentication using the provided details
- Authentication authentication = authenticationManager.authenticate(
+ final var authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password())
);
@@ -71,17 +67,17 @@ public AuthController(
/// The token refresh endpoint. Validates refresh tokens and returns new access tokens.
///
- /// @param refreshTokenRequest the [AuthDTO.RefreshTokenRequest] request body
- /// @return a [ResponseEntity] containing a [AuthDTO.RefreshTokenResponse]
+ /// @param refreshTokenRequest the refresh token request body
+ /// @return a token refresh response
@PostMapping("/api/auth/refresh")
public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) {
final var targetUserEntity = userRepository.findUserByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found"));
// Validate the existing refresh token
- final UserEntity userEntity = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUserEntity);
+ final var userEntity = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUserEntity);
// Generate new access token
- final String newAccessToken = tokenService.generateAccessToken(userEntity);
+ final var newAccessToken = tokenService.generateAccessToken(userEntity);
// Format the token and expiration time into a DTO
final var response = new AuthDTO.RefreshTokenResponse(newAccessToken, String.valueOf(tokenService.getExpirationTime()));
diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java
index ea35256..5a7edf4 100644
--- a/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java
+++ b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java
@@ -1,12 +1,10 @@
package org.openpodcastapi.opa.controllers.web;
-import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/// Controller for the hosted documentation endpoints
@Controller
-@Log4j2
public class DocsController {
/// The hosted documentation endpoint. Redirects users to the index page.
diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java
index 3580fee..1524957 100644
--- a/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java
+++ b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java
@@ -1,15 +1,11 @@
package org.openpodcastapi.opa.controllers.web;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/// Controller for the home and landing page controllers
@Controller
-@RequiredArgsConstructor
-@Log4j2
public class HomeController {
/// Controller for the landing page.
@@ -23,7 +19,7 @@ public String getLandingPage() {
/// Controller for an authenticated user's homepage.
/// Redirects users to the login page if they're not authenticated.
///
- /// @param auth the [Authentication] object for the user
+ /// @param auth the authentication object for the user
/// @return the home page
@GetMapping("/home")
public String getHomePage(Authentication auth) {
diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java
index 1a2322e..acc7472 100644
--- a/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java
+++ b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java
@@ -1,8 +1,6 @@
package org.openpodcastapi.opa.controllers.web;
import jakarta.validation.Valid;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
import org.openpodcastapi.opa.user.UserDTO;
import org.openpodcastapi.opa.user.UserService;
import org.springframework.dao.DataIntegrityViolationException;
@@ -16,13 +14,18 @@
/// Controller for the web authentication endpoints
@Controller
-@Log4j2
-@RequiredArgsConstructor
public class WebAuthController {
private static final String USER_REQUEST_ATTRIBUTE = "createUserRequest";
private static final String REGISTER_TEMPLATE = "auth/register";
private final UserService userService;
+ /// Constructor for the web auth controller
+ ///
+ /// @param userService the [UserService] class to handle user interactions
+ public WebAuthController(UserService userService) {
+ this.userService = userService;
+ }
+
/// Controller for the login page.
/// Displays an error message if a previous login was unsuccessful.
///
@@ -60,8 +63,8 @@ public String getRegister(Model model) {
/// Controller for the account registration form.
///
/// @param createUserRequest the [UserDTO.CreateUserDTO] containing the new account details
- /// @param result the [BindingResult] for displaying data validation errors
- /// @param model a placeholder for additional data to be passed to Thymeleaf
+ /// @param result the [BindingResult] for displaying data validation errors
+ /// @param model a placeholder for additional data to be passed to Thymeleaf
/// @return a redirect to the login page, if successful
@PostMapping("/register")
public String processRegistration(
diff --git a/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java b/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java
index 96d192c..0b5f3fb 100644
--- a/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java
+++ b/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java
@@ -7,7 +7,7 @@
///
/// @param timestamp the timestamp at which the error occurred
/// @param status the HTTP status code
-/// @param errors a list of [FieldError] objects
+/// @param errors a list of field errors
public record ValidationErrorResponse(
Instant timestamp,
int status,
diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java
index c0fc33e..51d7827 100644
--- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java
+++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java
@@ -1,23 +1,16 @@
package org.openpodcastapi.opa.security;
import jakarta.persistence.*;
-import lombok.*;
import org.openpodcastapi.opa.user.UserEntity;
import java.time.Instant;
/// Entity for refresh tokens
@Entity
-@Getter
-@Setter
-@NoArgsConstructor
-@AllArgsConstructor
-@Builder
@Table(name = "refresh_tokens")
public class RefreshTokenEntity {
/// The token ID
@Id
- @Generated
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@@ -27,6 +20,7 @@ public class RefreshTokenEntity {
/// The user that owns the token
@ManyToOne(optional = false, fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id")
private UserEntity user;
/// The date at which the token expires
@@ -37,6 +31,77 @@ public class RefreshTokenEntity {
@Column(nullable = false)
private Instant createdAt;
+ /// No-args constructor
+ public RefreshTokenEntity() {
+ }
+
+ /// Required-args constructor
+ ///
+ /// @param tokenHash the hash of the token
+ /// @param user the user associated with the token
+ /// @param expiresAt the expiry date of the token
+ public RefreshTokenEntity(String tokenHash, UserEntity user, Instant expiresAt) {
+ this.tokenHash = tokenHash;
+ this.user = user;
+ this.expiresAt = expiresAt;
+ }
+
+ /// Retrieves the ID of the refresh token entity
+ ///
+ /// @return the ID of the entity
+ public Long getId() {
+ return id;
+ }
+
+ /// Retrieves the token hash for a token
+ ///
+ /// @return the token hash
+ public String getTokenHash() {
+ return tokenHash;
+ }
+
+ /// Retrieves the user associated with a refresh token
+ ///
+ /// @return the user associated with the token
+ public UserEntity getUser() {
+ return user;
+ }
+
+ /// Assigns a user to a refresh token
+ ///
+ /// @param user the user associated with the token
+ public void setUser(UserEntity user) {
+ this.user = user;
+ }
+
+ /// Returns the expiry date of a token
+ ///
+ /// @return the expiry date for the token
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ /// Sets the expiry date for a token
+ ///
+ /// @param expiresAt the expiry date of the token
+ public void setExpiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ /// Retrieves the creation date for a token
+ ///
+ /// @return the creation date of the token
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ /// Sets the created date for a token
+ ///
+ /// @param createdAt the created date of the token
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
/// Performs actions on initial save
@PrePersist
public void prePersist() {
diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java
index 11d1d96..0decacd 100644
--- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java
+++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java
@@ -1,6 +1,6 @@
package org.openpodcastapi.opa.security;
-import lombok.NonNull;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.user.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
diff --git a/src/main/java/org/openpodcastapi/opa/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java
index f70623b..9b3c3bf 100644
--- a/src/main/java/org/openpodcastapi/opa/security/TokenService.java
+++ b/src/main/java/org/openpodcastapi/opa/security/TokenService.java
@@ -2,7 +2,6 @@
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
-import lombok.RequiredArgsConstructor;
import org.openpodcastapi.opa.user.UserEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -16,32 +15,52 @@
/// Service for refresh token and JWT-related actions
@Service
-@RequiredArgsConstructor
public class TokenService {
private final RefreshTokenRepository repository;
private final BCryptPasswordEncoder passwordEncoder;
-
// The secret string used to generate secret keys
@Value("${jwt.secret}")
private String secret;
-
// The TTL for each JWT, in minutes
@Value("${jwt.expiration-minutes:15}")
private long accessTokenMinutes;
-
// The TTL for each refresh token, in days
@Value("${jwt.refresh-days:7}")
private long refreshTokenDays;
-
@Value("${jwt.ttl}")
private String jwtExpiration;
- // The calculated secret key
+ /// Required args constructor
+ ///
+ /// @param repository the refresh token repository for token interaction
+ /// @param passwordEncoder the password encoder for encoding tokens
+ public TokenService(RefreshTokenRepository repository, BCryptPasswordEncoder passwordEncoder) {
+ this.repository = repository;
+ this.passwordEncoder = passwordEncoder;
+ }
+
+ /// The calculated secret key
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
+ /// Calculates the token expiry date from a given timestamp
+ ///
+ /// @param fromDate the date from which to calculate the expiry
+ /// @return a formatted expiry date
+ private Date calculateAccessTokenExpiryDate(Instant fromDate) {
+ return Date.from(fromDate.plusSeconds(accessTokenMinutes * 60));
+ }
+
+ /// Calculates the refresh token expiry time from a given timestamp
+ ///
+ /// @param fromDate the date from which to calculate the expiry
+ /// @return the time to expiry in seconds
+ private Instant calculateRefreshTokenExpiry(Instant fromDate) {
+ return fromDate.plusSeconds(refreshTokenDays * 24 * 3600);
+ }
+
/// Returns the expiration time for JWTs
///
/// @return a number representing the user-defined TTL of JWT tokens
@@ -51,33 +70,29 @@ public long getExpirationTime() {
/// Generates an access token for a given user
///
- /// @param userEntity the [UserEntity] to generate a token for
+ /// @param userEntity the user to generate a token for
/// @return the generated token
public String generateAccessToken(UserEntity userEntity) {
- Instant now = Instant.now();
+ final var now = Instant.now();
return Jwts.builder()
.subject(userEntity.getUuid().toString())
.claim("username", userEntity.getUsername())
.issuedAt(Date.from(now))
- .expiration(Date.from(now.plusSeconds(accessTokenMinutes * 60)))
+ .expiration(calculateAccessTokenExpiryDate(Instant.now()))
.signWith(key())
.compact();
}
/// Generates a refresh token for a given user
///
- /// @param userEntity the [UserEntity] to generate a refresh token for
+ /// @param userEntity the user to generate a refresh token for
/// @return the generated refresh token
public String generateRefreshToken(UserEntity userEntity) {
- String raw = UUID.randomUUID().toString() + UUID.randomUUID();
- String hash = passwordEncoder.encode(raw);
+ final var raw = UUID.randomUUID().toString() + UUID.randomUUID();
+ final var hash = passwordEncoder.encode(raw);
+ final var expiryDate = calculateRefreshTokenExpiry(Instant.now());
- RefreshTokenEntity token = RefreshTokenEntity.builder()
- .tokenHash(hash)
- .user(userEntity)
- .createdAt(Instant.now())
- .expiresAt(Instant.now().plusSeconds(refreshTokenDays * 24 * 3600))
- .build();
+ final var token = new RefreshTokenEntity(hash, userEntity, expiryDate);
repository.save(token);
return raw;
@@ -86,8 +101,8 @@ public String generateRefreshToken(UserEntity userEntity) {
/// Validates the refresh token for a user and updates its expiry time
///
/// @param rawToken the raw token to validate
- /// @param userEntity the [UserEntity] to validate the token for
- /// @return the validated [UserEntity]
+ /// @param userEntity the user to validate the token for
+ /// @return the validated user
public UserEntity validateRefreshToken(String rawToken, UserEntity userEntity) {
// Only fetch refresh tokens for the requesting user
for (RefreshTokenEntity token : repository.findAllByUser(userEntity)) {
@@ -95,8 +110,8 @@ public UserEntity validateRefreshToken(String rawToken, UserEntity userEntity) {
if (passwordEncoder.matches(rawToken, token.getTokenHash()) &&
token.getExpiresAt().isAfter(Instant.now())) {
// Update the expiry date on the refresh token
- token.setExpiresAt(Instant.now().plusSeconds(refreshTokenDays * 24 * 3600));
- final RefreshTokenEntity updatedToken = repository.save(token);
+ token.setExpiresAt(calculateRefreshTokenExpiry(Instant.now()));
+ final var updatedToken = repository.save(token);
// Return the user to confirm the token is valid
return updatedToken.getUser();
diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java
index e0e75b7..8d6fdc5 100644
--- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java
+++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java
@@ -1,6 +1,6 @@
package org.openpodcastapi.opa.service;
-import lombok.NonNull;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.user.UserRoles;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java
index 61947dd..442656c 100644
--- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java
+++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java
@@ -1,7 +1,6 @@
package org.openpodcastapi.opa.service;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.user.UserEntity;
import org.openpodcastapi.opa.user.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
@@ -9,12 +8,21 @@
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
+import java.util.Set;
+import java.util.stream.Collectors;
+
/// Custom service for mapping user details
@Service
-@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
+ /// Required-args constructor
+ ///
+ /// @param userRepository the user repository for user interactions
+ public CustomUserDetailsService(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
/// Returns a mapped custom user details model by username
///
/// @param username the username to map
@@ -28,14 +36,17 @@ public class CustomUserDetailsService implements UserDetailsService {
/// Maps a user to a custom user details model
///
- /// @param userEntity the [UserEntity] model to map
+ /// @param userEntity the user model to map
private CustomUserDetails mapToUserDetails(UserEntity userEntity) {
return new CustomUserDetails(
userEntity.getId(),
userEntity.getUuid(),
userEntity.getUsername(),
userEntity.getPassword(),
- userEntity.getUserRoles()
+ userEntity.getUserRoles() == null
+ ? Set.of()
+ : userEntity.getUserRoles().stream()
+ .collect(Collectors.toUnmodifiableSet())
);
}
diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java
index 18162f7..76aa9c1 100644
--- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java
+++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java
@@ -1,11 +1,12 @@
package org.openpodcastapi.opa.subscription;
+import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
-import lombok.NonNull;
import org.hibernate.validator.constraints.URL;
import org.hibernate.validator.constraints.UUID;
+import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import java.time.Instant;
@@ -30,6 +31,7 @@ public record SubscriptionCreateDTO(
/// @param createdAt the date at which the subscription link was created
/// @param updatedAt the date at which the subscription link was last updated
/// @param unsubscribedAt the date at which the user unsubscribed from the feed
+ @JsonInclude(JsonInclude.Include.NON_NULL)
public record UserSubscriptionDTO(
@JsonProperty(required = true) @UUID java.util.UUID uuid,
@JsonProperty(required = true) @URL String feedUrl,
@@ -63,7 +65,7 @@ public record SubscriptionFailureDTO(
/// A paginated DTO representing a list of subscriptions
///
- /// @param subscriptions the [UserSubscriptionDTO] list representing the subscriptions
+ /// @param subscriptions the DTO list representing the subscriptions
/// @param first whether this is the first page
/// @param last whether this is the last page
/// @param page the current page number
@@ -81,10 +83,10 @@ public record SubscriptionPageDTO(
int numberOfElements,
int size
) {
- /// Returns a paginated response with details from a [Page] of user subscriptions
+ /// Returns a paginated response with details from a page of user subscriptions
///
- /// @param page the [Page] of [UserSubscriptionDTO] items
- /// @return a [SubscriptionPageDTO] with pagination details filled out
+ /// @param page the paginated list of DTO items
+ /// @return a subscription DTO with pagination details filled out
public static SubscriptionPageDTO fromPage(Page<@NonNull UserSubscriptionDTO> page) {
return new SubscriptionPageDTO(
page.getContent(),
diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java
index e12b7ac..efa6b32 100644
--- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java
+++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java
@@ -1,7 +1,6 @@
package org.openpodcastapi.opa.subscription;
import jakarta.persistence.*;
-import lombok.*;
import java.time.Instant;
import java.util.Set;
@@ -9,17 +8,11 @@
/// An entity representing a subscription wrapper
@Entity
-@NoArgsConstructor
-@AllArgsConstructor
-@Builder
-@Getter
-@Setter
@Table(name = "subscriptions")
public class SubscriptionEntity {
/// The subscription ID
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
- @Generated
private Long id;
/// The UUID of the subscription.
@@ -31,8 +24,8 @@ public class SubscriptionEntity {
@Column(nullable = false)
private String feedUrl;
- /// A list of [UserSubscriptionEntity] associated with the subscription
- @OneToMany(mappedBy = "subscription")
+ /// A list of user subscriptions associated with the subscription
+ @OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE)
private Set subscribers;
/// The date at which the subscription was created
@@ -43,6 +36,19 @@ public class SubscriptionEntity {
@Column(nullable = false)
private Instant updatedAt;
+ /// No-args constructor
+ public SubscriptionEntity() {
+ }
+
+ /// Required-args constructor
+ ///
+ /// @param uuid the UUID of the subscription
+ /// @param feedUrl the feed URL of the subscription
+ public SubscriptionEntity(UUID uuid, String feedUrl) {
+ this.uuid = uuid;
+ this.feedUrl = feedUrl;
+ }
+
/// Performs actions when the entity is initially saved
@PrePersist
public void prePersist() {
@@ -58,4 +64,67 @@ public void preUpdate() {
// Store the timestamp of the update
this.setUpdatedAt(Instant.now());
}
+
+ /// Retrieves the ID of a subscription
+ ///
+ /// @return the ID of the subscription
+ public Long getId() {
+ return this.id;
+ }
+
+ /// Retrieves the UUID of a subscription
+ ///
+ /// @return the UUID of the subscription
+ public UUID getUuid() {
+ return this.uuid;
+ }
+
+ /// Sets the UUID of a subscription
+ ///
+ /// @param uuid the UUID of the subscription
+ public void setUuid(UUID uuid) {
+ this.uuid = uuid;
+ }
+
+ /// Retrieves the feed URL of a subscription
+ ///
+ /// @return the feed URL associated with a subscription
+ public String getFeedUrl() {
+ return this.feedUrl;
+ }
+
+ /// Sets the feed URL of a subscription
+ ///
+ /// @param feedUrl the feed URL
+ public void setFeedUrl(String feedUrl) {
+ this.feedUrl = feedUrl;
+ }
+
+ /// Retrieves the creation date of a subscription
+ ///
+ /// @return the creation date of the subscription
+ public Instant getCreatedAt() {
+ return this.createdAt;
+ }
+
+ /// Sets the creation date of a subscription
+ ///
+ /// @param createdAt the creation date of the subscription
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ /// Retrieves the last update time of a subscription
+ ///
+ /// @return the date at which the subscription was last updated
+ public Instant getUpdatedAt() {
+ return this.updatedAt;
+ }
+
+ /// Sets the last update time of a subscription
+ ///
+ /// @param updatedAt the date at which the subscription was last updated
+ public void setUpdatedAt(Instant updatedAt) {
+ this.updatedAt = updatedAt;
+ }
}
diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java
index 87c19e5..498c8fc 100644
--- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java
+++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java
@@ -8,13 +8,12 @@
/// Mapper for subscription items
@Mapper(componentModel = "spring")
public interface SubscriptionMapper {
- /// Maps a [SubscriptionDTO.SubscriptionCreateDTO] to a [SubscriptionEntity]
+ /// Maps a DTO to a subscription entity
///
- /// @param dto the [SubscriptionDTO.SubscriptionCreateDTO] to map
- /// @return a mapped [SubscriptionEntity]
- @Mapping(target = "id", ignore = true)
+ /// @param dto the DTO to map
+ /// @return a mapped subscription entity
@Mapping(target = "uuid", source = "uuid")
- @Mapping(target = "subscribers", ignore = true)
+ @Mapping(target = "feedUrl", source = "feedUrl")
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
SubscriptionEntity toEntity(SubscriptionDTO.SubscriptionCreateDTO dto);
@@ -22,7 +21,7 @@ public interface SubscriptionMapper {
/// Maps a string UUID to a UUID instance
///
/// @param feedUUID the string UUID to map
- /// @return the mapped [UUID] instance
+ /// @return the mapped UUID instance
default UUID mapStringToUUID(String feedUUID) {
return UUID.fromString(feedUUID);
}
diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java
index 79f7c99..78852d7 100644
--- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java
+++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java
@@ -1,6 +1,6 @@
package org.openpodcastapi.opa.subscription;
-import lombok.NonNull;
+import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@@ -12,7 +12,7 @@
public interface SubscriptionRepository extends JpaRepository<@NonNull SubscriptionEntity, @NonNull Long> {
/// Finds a single subscription by UUID. Returns `null` if no value exists.
///
- /// @param uuid the [UUID] to match
- /// @return the matching [SubscriptionEntity]
+ /// @param uuid the UUID to match
+ /// @return the matching subscription
Optional findByUuid(UUID uuid);
}
diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java
index 4f397e3..c99d355 100644
--- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java
+++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java
@@ -1,10 +1,9 @@
package org.openpodcastapi.opa.subscription;
import jakarta.persistence.EntityNotFoundException;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.service.CustomUserDetails;
+import org.slf4j.Logger;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
@@ -16,26 +15,33 @@
import java.util.List;
import java.util.UUID;
+import static org.slf4j.LoggerFactory.getLogger;
+
/// Controller for API subscription requests
@RestController
-@RequiredArgsConstructor
-@Log4j2
@RequestMapping("/api/v1/subscriptions")
public class SubscriptionRestController {
+ private static final Logger log = getLogger(SubscriptionRestController.class);
private final SubscriptionService service;
+ /// Required-args constructor
+ ///
+ /// @param service the service used for subscription actions
+ public SubscriptionRestController(SubscriptionService service) {
+ this.service = service;
+ }
+
/// Returns all subscriptions for a given user
///
- /// @param user the [CustomUserDetails] of the authenticated user
- /// @param pageable the [Pageable] pagination object
+ /// @param user the custom user details of the authenticated user
+ /// @param pageable the pagination options
/// @param includeUnsubscribed whether to include unsubscribed feeds in the response
- /// @return a [ResponseEntity] containing [SubscriptionDTO.SubscriptionPageDTO] objects
+ /// @return a response containing subscription objects
@GetMapping
- @ResponseStatus(HttpStatus.OK)
@PreAuthorize("hasRole('USER')")
public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) {
log.info("{}", user.getAuthorities());
- Page dto;
+ final Page dto;
if (includeUnsubscribed) {
dto = service.getAllSubscriptionsForUser(user.id(), pageable);
@@ -43,20 +49,17 @@ public class SubscriptionRestController {
dto = service.getAllActiveSubscriptionsForUser(user.id(), pageable);
}
- log.debug("{}", dto);
-
return new ResponseEntity<>(SubscriptionDTO.SubscriptionPageDTO.fromPage(dto), HttpStatus.OK);
}
/// Returns a single subscription entry by UUID
///
/// @param uuid the UUID value to query for
- /// @param user the [CustomUserDetails] for the user
- /// @return a [ResponseEntity] containing a [SubscriptionDTO.UserSubscriptionDTO] object
+ /// @param user the custom user details for the user
+ /// @return a response containing a subscription DTO
/// @throws EntityNotFoundException if no entry is found
/// @throws IllegalArgumentException if the UUID is improperly formatted
@GetMapping("/{uuid}")
- @ResponseStatus(HttpStatus.OK)
@PreAuthorize("hasRole('USER')")
public ResponseEntity getSubscriptionByUuid(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) throws EntityNotFoundException {
// Attempt to validate the UUID value from the provided string
@@ -66,19 +69,18 @@ public class SubscriptionRestController {
// Fetch the subscription, throw an EntityNotFoundException if this fails
final var dto = service.getUserSubscriptionBySubscriptionUuid(uuidValue, user.id());
- // Return the mapped subscriptionEntity entry
+ // Return the mapped subscription entry
return new ResponseEntity<>(dto, HttpStatus.OK);
}
/// Updates the subscription status of a subscription for a given user
///
/// @param uuid the UUID of the subscription to update
- /// @param user the [CustomUserDetails] for the user
- /// @return a [ResponseEntity] containing a [SubscriptionDTO.UserSubscriptionDTO] object
+ /// @param user the custom user details for the user
+ /// @return a reponse containing a subscription DTO
/// @throws EntityNotFoundException if no entry is found
/// @throws IllegalArgumentException if the UUID is improperly formatted
@PostMapping("/{uuid}/unsubscribe")
- @ResponseStatus(HttpStatus.OK)
@PreAuthorize("hasRole('USER')")
public ResponseEntity unsubscribeUserFromFeed(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) {
// Attempt to validate the UUID value from the provided string
@@ -90,11 +92,11 @@ public class SubscriptionRestController {
return new ResponseEntity<>(dto, HttpStatus.OK);
}
- /// Bulk creates [UserSubscriptionEntity] objects for a user. Creates new [SubscriptionEntity] objects if not already present
+ /// Bulk creates user subscriptions for a user. Creates new subscriptions if not already present
///
- /// @param request a list of [SubscriptionDTO.SubscriptionCreateDTO] objects
- /// @param user the [CustomUserDetails] for the user
- /// @return a [ResponseEntity] containing a [SubscriptionDTO.BulkSubscriptionResponseDTO] object
+ /// @param request a list of subscription creation DTOs
+ /// @param user the custom user details for the user
+ /// @return a response containing a bulk subscription DTO
@PostMapping
@PreAuthorize("hasRole('USER')")
public ResponseEntity createUserSubscriptions(@RequestBody List request, @AuthenticationPrincipal CustomUserDetails user) {
diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java
index ffedb36..5807355 100644
--- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java
+++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java
@@ -1,10 +1,9 @@
package org.openpodcastapi.opa.subscription;
import jakarta.persistence.EntityNotFoundException;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.user.UserRepository;
+import org.slf4j.Logger;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@@ -15,27 +14,43 @@
import java.util.List;
import java.util.UUID;
+import static org.slf4j.LoggerFactory.getLogger;
+
/// Service for subscription-related actions
@Service
-@RequiredArgsConstructor
-@Log4j2
public class SubscriptionService {
+ private static final Logger log = getLogger(SubscriptionService.class);
private final SubscriptionRepository subscriptionRepository;
private final SubscriptionMapper subscriptionMapper;
private final UserSubscriptionRepository userSubscriptionRepository;
private final UserSubscriptionMapper userSubscriptionMapper;
private final UserRepository userRepository;
+ /// All-args constructor
+ ///
+ /// @param subscriptionRepository the repository used for subscription interactions
+ /// @param subscriptionMapper the mapper used for mapping subscription entities and DTOs
+ /// @param userSubscriptionRepository the repository used for user subscription interactions
+ /// @param userSubscriptionMapper the mapper used for mapping user subscription entities and DTOs
+ /// @param userRepository the repository used for user interactions
+ public SubscriptionService(SubscriptionRepository subscriptionRepository, SubscriptionMapper subscriptionMapper, UserSubscriptionRepository userSubscriptionRepository, UserSubscriptionMapper userSubscriptionMapper, UserRepository userRepository) {
+ this.subscriptionRepository = subscriptionRepository;
+ this.subscriptionMapper = subscriptionMapper;
+ this.userSubscriptionRepository = userSubscriptionRepository;
+ this.userSubscriptionMapper = userSubscriptionMapper;
+ this.userRepository = userRepository;
+ }
+
/// Fetches an existing repository from the database or creates a new one if none is found
///
- /// @param dto the [SubscriptionDTO.SubscriptionCreateDTO] containing the subscription data
- /// @return the fetched or created [SubscriptionEntity]
+ /// @param dto the DTO containing the subscription data
+ /// @return the fetched or created subscription
protected SubscriptionEntity fetchOrCreateSubscription(SubscriptionDTO.SubscriptionCreateDTO dto) {
final var feedUuid = UUID.fromString(dto.uuid());
return subscriptionRepository
.findByUuid(feedUuid)
.orElseGet(() -> {
- log.debug("Creating new subscription with UUID {}", dto.uuid());
+ log.info("Creating new subscription with UUID {} and feed URL {}", dto.uuid(), dto.feedUrl());
return subscriptionRepository.save(subscriptionMapper.toEntity(dto));
});
}
@@ -44,10 +59,10 @@ protected SubscriptionEntity fetchOrCreateSubscription(SubscriptionDTO.Subscript
///
/// @param subscriptionUuid the UUID of the subscription
/// @param userId the database ID of the user
- /// @return a [SubscriptionDTO.UserSubscriptionDTO] of the user subscription
+ /// @return a DTO of the user subscription
/// @throws EntityNotFoundException if no entry is found
@Transactional(readOnly = true)
- public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid(UUID subscriptionUuid, Long userId) {
+ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid(UUID subscriptionUuid, Long userId) throws EntityNotFoundException {
log.debug("Fetching subscription {} for userEntity {}", subscriptionUuid, userId);
final var userSubscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionUuid)
.orElseThrow(() -> new EntityNotFoundException("subscription not found for userEntity"));
@@ -59,8 +74,8 @@ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid
/// Gets all subscriptions for the authenticated userEntity
///
/// @param userId the database ID of the authenticated userEntity
- /// @param pageable the [Pageable] object containing pagination options
- /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects
+ /// @param pageable the pagination options
+ /// @return a paginated set of user subscriptions
@Transactional(readOnly = true)
public Page getAllSubscriptionsForUser(Long userId, Pageable pageable) {
log.debug("Fetching subscriptions for {}", userId);
@@ -72,20 +87,23 @@ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid
/// Gets all active subscriptions for the authenticated user
///
/// @param userId the database ID of the authenticated user
- /// @param pageable the [Pageable] object containing pagination options
- /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects
+ /// @param pageable the pagination options
+ /// @return a paginated set of user subscriptions
@Transactional(readOnly = true)
public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) {
log.debug("Fetching all active subscriptions for {}", userId);
- return userSubscriptionRepository.findAllByUserIdAndUnsubscribedAtNotEmpty(userId, pageable).map(userSubscriptionMapper::toDto);
+ log.info("{}", userId);
+ var thing = userSubscriptionRepository.findAll();
+ thing.forEach(entity -> log.info("{}, {}", entity.getUser().getId(), entity.getUnsubscribedAt()));
+ return userSubscriptionRepository.findAllByUserIdAndUnsubscribedAtIsNull(userId, pageable).map(userSubscriptionMapper::toDto);
}
/// Persists a new user subscription to the database
/// If an existing entry is found for the user and subscription, the `isSubscribed` property is set to `true`
///
- /// @param subscriptionEntity the target [SubscriptionEntity]
+ /// @param subscriptionEntity the target subscription
/// @param userId the ID of the target user
- /// @return a [SubscriptionDTO.UserSubscriptionDTO] representation of the subscription link
+ /// @return a response containing a user subscription DTO
/// @throws EntityNotFoundException if no matching user is found
protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(SubscriptionEntity subscriptionEntity, Long userId) {
final var userEntity = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("user not found"));
@@ -105,13 +123,13 @@ protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(Subscripti
return userSubscriptionMapper.toDto(userSubscriptionRepository.save(newSubscription));
}
- /// Creates [UserSubscriptionEntity] links in bulk. If the [SubscriptionEntity] isn't already in the system, this is added before the user is subscribed.
+ /// Creates user subscriptions in bulk. If the subscription isn't already in the system, this is added before the user is subscribed.
///
- /// @param requests a list of [SubscriptionDTO.SubscriptionCreateDTO] objects to create
+ /// @param requests a list of subscriptions to create
/// @param userId the ID of the requesting user
- /// @return a [SubscriptionDTO.BulkSubscriptionResponseDTO] DTO containing a list of successes and failures
+ /// @return a response containing a bulk creation DTO
@Transactional
- public SubscriptionDTO.BulkSubscriptionResponseDTO addSubscriptions(List requests, Long userId) {
+ public SubscriptionDTO.@NonNull BulkSubscriptionResponseDTO addSubscriptions(List requests, Long userId) {
List successes = new ArrayList<>();
List failures = new ArrayList<>();
@@ -121,7 +139,6 @@ public SubscriptionDTO.BulkSubscriptionResponseDTO addSubscriptions(List {
- /// Finds an individual [UserSubscriptionEntity] by user ID and feed UUID.
+ /// Finds an individual user subscription by user ID and feed UUID.
/// Returns `null` if no matching value is found.
///
/// @param userId the ID of the user
/// @param subscriptionUuid the UUID of the subscription
- /// @return a [UserSubscriptionEntity], if one matches
+ /// @return a user subscription, if one matches
Optional findByUserIdAndSubscriptionUuid(Long userId, UUID subscriptionUuid);
- /// Returns a paginated list of [UserSubscriptionEntity] objects associated with a user.
+ /// Returns a paginated list of user subscription objects associated with a user.
///
/// @param userId the ID of the associated user
- /// @param pageable the [Pageable] object containing pagination information
- /// @return a [Page] of [UserSubscriptionEntity] associated with the user
+ /// @param pageable the pagination options
+ /// @return a paginated list of user subscription associated with the user
Page<@NonNull UserSubscriptionEntity> findAllByUserId(Long userId, Pageable pageable);
- /// Returns a paginated list of [UserSubscriptionEntity] for a user where the [UserSubscriptionEntity#unsubscribedAt]
- /// field is not empty.
+ /// Returns a paginated list of active user subscriptions for a user.
///
/// @param userId the ID of the associated user
- /// @param pageable the [Pageable] object containing pagination information
- /// @return a [Page] of [UserSubscriptionEntity] associated with the user
- Page<@NonNull UserSubscriptionEntity> findAllByUserIdAndUnsubscribedAtNotEmpty(Long userId, Pageable pageable);
+ /// @param pageable the pagination options
+ /// @return a paginated list of user subscription associated with the user
+ Page<@NonNull UserSubscriptionEntity> findAllByUserIdAndUnsubscribedAtIsNull(Long userId, Pageable pageable);
}
diff --git a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java
index 5ad824f..54658e5 100644
--- a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java
+++ b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java
@@ -3,7 +3,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
-import lombok.NonNull;
+import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import java.time.Instant;
@@ -30,7 +30,7 @@ public record UserResponseDTO(
/// A paginated DTO representing a list of subscriptions
///
- /// @param users the [UserResponseDTO] list representing the users
+ /// @param users the DTO list representing the users
/// @param first whether this is the first page
/// @param last whether this is the last page
/// @param page the current page number
@@ -48,10 +48,10 @@ public record UserPageDTO(
int numberOfElements,
int size
) {
- /// Returns a paginated response with details from a [Page] of users
+ /// Returns a paginated response with details from a paginated list of users
///
- /// @param page the [Page] of [UserResponseDTO] items
- /// @return a [UserPageDTO] with pagination details filled out
+ /// @param page a paginated list of user DTOs
+ /// @return a DTO with pagination details filled out
public static UserPageDTO fromPage(Page<@NonNull UserResponseDTO> page) {
return new UserPageDTO(
page.getContent(),
diff --git a/src/main/java/org/openpodcastapi/opa/user/UserEntity.java b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java
index a47aa3f..88ca380 100644
--- a/src/main/java/org/openpodcastapi/opa/user/UserEntity.java
+++ b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java
@@ -1,10 +1,9 @@
package org.openpodcastapi.opa.user;
import jakarta.persistence.*;
-import lombok.*;
+import org.openpodcastapi.opa.security.RefreshTokenEntity;
import org.openpodcastapi.opa.subscription.UserSubscriptionEntity;
-import java.io.Serializable;
import java.time.Instant;
import java.util.Collections;
import java.util.HashSet;
@@ -14,16 +13,10 @@
/// An entity representing a user
@Entity
@Table(name = "users")
-@Builder
-@Getter
-@Setter
-@NoArgsConstructor
-@AllArgsConstructor
-public class UserEntity implements Serializable {
+public class UserEntity {
/// The user ID
@Id
- @Generated
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@@ -43,13 +36,16 @@ public class UserEntity implements Serializable {
@Column(nullable = false, unique = true)
private String email;
- /// A list of [UserSubscriptionEntity] associated with the user
+ /// A list of user subscriptions associated with the user
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
- private transient Set subscriptions;
+ private Set subscriptions;
+
+ /// A list of refresh tokens associated with the user
+ @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
+ private Set refreshTokens;
/// The user's associated roles
@ElementCollection(fetch = FetchType.EAGER)
- @Builder.Default
@Enumerated(EnumType.STRING)
@CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
private Set userRoles = new HashSet<>(Collections.singletonList(UserRoles.USER));
@@ -61,20 +57,158 @@ public class UserEntity implements Serializable {
/// The date at which the entity was last updated
private Instant updatedAt;
+ /// No-args constructor
+ public UserEntity() {
+ }
+
+ /// Required-args constructor
+ ///
+ /// @param id the ID of the user
+ /// @param uuid the UUID of the user
+ /// @param username the username of the user
+ /// @param email the email address of the user
+ public UserEntity(Long id, UUID uuid, String username, String email) {
+ this(id, uuid, username, "", email, Instant.now(), Instant.now());
+ }
+
+ /// All-args constructor
+ ///
+ /// @param id the ID of the user
+ /// @param uuid the UUID of the user
+ /// @param username the user's username
+ /// @param password the user's hashed password
+ /// @param email the user's email address
+ /// @param createdAt the date at which the user was created
+ /// @param updatedAt the date at which the user was last updated
+ public UserEntity(Long id, UUID uuid, String username, String password, String email, Instant createdAt, Instant updatedAt) {
+ this.id = id;
+ this.uuid = uuid;
+ this.username = username;
+ this.password = password;
+ this.email = email;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+
+ /// Retrieves the ID of a user entity
+ ///
+ /// @return the ID of the user entity
+ public Long getId() {
+ return this.id;
+ }
+
+ /// Retrieves the UUID of a user entity
+ ///
+ /// @return the UUID of the user entity
+ public UUID getUuid() {
+ return this.uuid;
+ }
+
+ /// Sets the UUID of a user entity
+ ///
+ /// @param uuid the UUID for the entity
+ public void setUuid(UUID uuid) {
+ this.uuid = uuid;
+ }
+
+ /// Retrieves the username of a user entity
+ ///
+ /// @return a user's username
+ public String getUsername() {
+ return this.username;
+ }
+
+ /// Sets the username of a user entity
+ ///
+ /// @param username the user's username
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ /// Retrieves the user's password hash
+ ///
+ /// @return the hashed password
+ public String getPassword() {
+ return this.password;
+ }
+
+ /// Sets the user's password. The password must be hashed first
+ ///
+ /// @param password the hashed password
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ /// Retrieves a user's email address
+ ///
+ /// @return the user's email address
+ public String getEmail() {
+ return this.email;
+ }
+
+ /// Sets the user's email address.
+ ///
+ /// @param email the user's email address
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ /// Retrieves a user's subscriptions
+ ///
+ /// @return a set of subscriptions
+ public Set getSubscriptions() {
+ return this.subscriptions;
+ }
+
+ /// Sets a user's subscriptions
+ ///
+ /// @param subscriptions the set of subscriptions to add to the user
+ public void setSubscriptions(Set subscriptions) {
+ this.subscriptions = subscriptions;
+ }
+
+ /// Retrieves a user's roles
+ ///
+ /// @return a set of user roles
+ public Set getUserRoles() {
+ return this.userRoles;
+ }
+
+ /// Sets a user's roles
+ ///
+ /// @param userRoles a set of user roles
+ public void setUserRoles(Set userRoles) {
+ this.userRoles = userRoles;
+ }
+
+ /// Retrieves the creation date of the user
+ ///
+ /// @return the user creation date
+ public Instant getCreatedAt() {
+ return this.createdAt;
+ }
+
+ /// Retrieves the last updated timestamp for the user
+ ///
+ /// @return the last updated timestamp
+ public Instant getUpdatedAt() {
+ return this.updatedAt;
+ }
+
/// Performs actions when the entity is initially saved
@PrePersist
public void prePersist() {
this.setUuid(UUID.randomUUID());
- final Instant timestamp = Instant.now();
+ final var timestamp = Instant.now();
// Store the created date and set an updated timestamp
- this.setCreatedAt(timestamp);
- this.setUpdatedAt(timestamp);
+ this.createdAt = timestamp;
+ this.updatedAt = timestamp;
}
/// Performs actions when the entity is updated
@PreUpdate
public void preUpdate() {
// Store the timestamp of the update
- this.setUpdatedAt(Instant.now());
+ this.updatedAt = Instant.now();
}
}
diff --git a/src/main/java/org/openpodcastapi/opa/user/UserMapper.java b/src/main/java/org/openpodcastapi/opa/user/UserMapper.java
index 63d0413..a412e77 100644
--- a/src/main/java/org/openpodcastapi/opa/user/UserMapper.java
+++ b/src/main/java/org/openpodcastapi/opa/user/UserMapper.java
@@ -6,24 +6,21 @@
/// Mapper for user items
@Mapper(componentModel = "spring")
public interface UserMapper {
- /// Maps a [UserEntity] to a [UserDTO.UserResponseDTO]
+ /// Maps a user entity to a DTO.
///
- /// @param userEntity the [UserEntity] to map
- /// @return the mapped [UserDTO.UserResponseDTO]
+ /// @param userEntity the entity to map
+ /// @return the mapped DTO
UserDTO.UserResponseDTO toDto(UserEntity userEntity);
- /// Maps a [UserDTO.CreateUserDTO] to a [UserEntity].
+ /// Maps a user creation DTO to an entity.
/// This mapper ignores all fields other than the username and email address.
/// Other items are populated prior to persistence.
///
- /// @param dto the [UserDTO.CreateUserDTO] to map
- /// @return the mapped [UserEntity]
+ /// @param dto the user creation DTO to map
+ /// @return the mapped entity
@Mapping(target = "uuid", ignore = true)
- @Mapping(target = "id", ignore = true)
@Mapping(target = "subscriptions", ignore = true)
@Mapping(target = "password", ignore = true)
@Mapping(target = "userRoles", ignore = true)
- @Mapping(target = "updatedAt", ignore = true)
- @Mapping(target = "createdAt", ignore = true)
UserEntity toEntity(UserDTO.CreateUserDTO dto);
}
diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRepository.java b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java
index 087d3e9..6eef061 100644
--- a/src/main/java/org/openpodcastapi/opa/user/UserRepository.java
+++ b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java
@@ -1,6 +1,6 @@
package org.openpodcastapi.opa.user;
-import lombok.NonNull;
+import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@@ -13,18 +13,18 @@ public interface UserRepository extends JpaRepository<@NonNull UserEntity, @NonN
/// Finds a single user by UUID. Returns `null` if no entity is found.
///
/// @param uuid the UUID of the user
- /// @return the found [UserEntity]
+ /// @return the found user
Optional findUserByUuid(UUID uuid);
/// Finds a single user by username. Returns `null` if no entity is found.
///
/// @param username the username of the user
- /// @return the found [UserEntity]
+ /// @return the found user
Optional findUserByUsername(String username);
/// Performs a check to see if there is an existing entity with the same username or email address
///
- /// @param email the email address to check
+ /// @param email the email address to check
/// @param username the username to check
/// @return a boolean value representing whether an existing user was found
boolean existsUserByEmailOrUsername(String email, String username);
diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java
index ac87019..dc2c27d 100644
--- a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java
+++ b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java
@@ -1,7 +1,6 @@
package org.openpodcastapi.opa.user;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
+import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -13,17 +12,22 @@
/// Controller for user-related API requests
@RestController
-@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserRestController {
private final UserService service;
+ /// Required-args constructor
+ ///
+ /// @param userService the user service used to handle user interactions
+ public UserRestController(UserService userService) {
+ this.service = userService;
+ }
+
/// Returns all users. Only accessible to admins.
///
- /// @param pageable the [Pageable] options used for pagination
- /// @return a [ResponseEntity] containing [UserDTO.UserPageDTO] objects
+ /// @param pageable the pagination options
+ /// @return a response containing user objects
@GetMapping
- @ResponseStatus(HttpStatus.OK)
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity getAllUsers(Pageable pageable) {
final var paginatedUserResponse = service.getAllUsers(pageable);
@@ -33,10 +37,9 @@ public class UserRestController {
/// Creates a new user in the system
///
- /// @param request a [UserDTO.CreateUserDTO] request body
- /// @return a [ResponseEntity] containing [UserDTO.UserResponseDTO] objects
+ /// @param request a user creation request body
+ /// @return a response containing user objects
@PostMapping
- @ResponseStatus(HttpStatus.CREATED)
public ResponseEntity createUser(@RequestBody @Validated UserDTO.CreateUserDTO request) {
// Create and persist the user
final var userResponseDTO = service.createAndPersistUser(request);
@@ -47,8 +50,8 @@ public class UserRestController {
/// Fetch a specific user by UUID
///
- /// @param uuid the [UUID] of the user
- /// @return a [ResponseEntity] containing a summary of the action
+ /// @param uuid the UUID of the user
+ /// @return a response containing a summary of the action
@DeleteMapping("/{uuid}")
@PreAuthorize("hasRole('ADMIN') or #uuid == principal.uuid")
public ResponseEntity<@NonNull String> deleteUser(@PathVariable String uuid) {
diff --git a/src/main/java/org/openpodcastapi/opa/user/UserService.java b/src/main/java/org/openpodcastapi/opa/user/UserService.java
index 1cf9086..e9e48f0 100644
--- a/src/main/java/org/openpodcastapi/opa/user/UserService.java
+++ b/src/main/java/org/openpodcastapi/opa/user/UserService.java
@@ -1,9 +1,8 @@
package org.openpodcastapi.opa.user;
import jakarta.persistence.EntityNotFoundException;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
+import org.jspecify.annotations.NonNull;
+import org.slf4j.Logger;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -15,18 +14,28 @@
/// Service class for user-related actions
@Service
-@RequiredArgsConstructor
-@Log4j2
public class UserService {
private static final String USER_NOT_FOUND = "User not found";
+ private static final Logger log = org.slf4j.LoggerFactory.getLogger(UserService.class);
private final UserRepository repository;
private final UserMapper mapper;
private final BCryptPasswordEncoder passwordEncoder;
+ /// Required-args constructor
+ ///
+ /// @param repository the user repository used for user interactions
+ /// @param mapper the user mapper used to map user entities and DTOs
+ /// @param passwordEncoder the password encoder used to handle user passwords
+ public UserService(UserRepository repository, UserMapper mapper, BCryptPasswordEncoder passwordEncoder) {
+ this.repository = repository;
+ this.mapper = mapper;
+ this.passwordEncoder = passwordEncoder;
+ }
+
/// Persists a user to the database
///
- /// @param dto the [UserDTO.CreateUserDTO] for the user
- /// @return the formatted [UserDTO.UserResponseDTO] representation of the user
+ /// @param dto the user creation DTO for the user
+ /// @return the formatted DTO representation of the user
/// @throws DataIntegrityViolationException if a user with a matching username or email address exists already
@Transactional
public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) throws DataIntegrityViolationException {
@@ -49,8 +58,8 @@ public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) t
/// Fetches a list of all users in the system.
/// Intended for use by admins only.
///
- /// @param pageable the [Pageable] object containing pagination options
- /// @return a [Page] iterable of [UserDTO.UserResponseDTO] objects
+ /// @param pageable the pagination options
+ /// @return a paginated list of user objects
@Transactional(readOnly = true)
public Page getAllUsers(Pageable pageable) {
final var paginatedUserDTO = repository.findAll(pageable);
@@ -62,7 +71,7 @@ public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) t
/// Deletes a user from the database
///
- /// @param uuid the [UUID] of the user to delete
+ /// @param uuid the UUID of the user to delete
/// @return a success message
/// @throws EntityNotFoundException if no matching record is found
@Transactional
diff --git a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java
index 1beb27e..d9d8c9a 100644
--- a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java
+++ b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java
@@ -1,11 +1,10 @@
package org.openpodcastapi.opa.util;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
+import org.jspecify.annotations.NonNull;
import org.openpodcastapi.opa.user.UserEntity;
import org.openpodcastapi.opa.user.UserRepository;
import org.openpodcastapi.opa.user.UserRoles;
+import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
@@ -14,11 +13,12 @@
import java.util.Set;
+import static org.slf4j.LoggerFactory.getLogger;
+
/// Creates a default admin user for the system
@Component
-@RequiredArgsConstructor
-@Log4j2
public class AdminUserInitializer implements ApplicationRunner {
+ private static final Logger log = getLogger(AdminUserInitializer.class);
private final UserRepository userRepository;
private final BCryptPasswordEncoder encoder;
@Value("${admin.username}")
@@ -28,6 +28,15 @@ public class AdminUserInitializer implements ApplicationRunner {
@Value("${admin.email}")
private String email;
+ /// Required-args constructor
+ ///
+ /// @param userRepository the user repository used for user interactions
+ /// @param encoder the password encoder used to encrypt the admin password
+ public AdminUserInitializer(UserRepository userRepository, BCryptPasswordEncoder encoder) {
+ this.userRepository = userRepository;
+ this.encoder = encoder;
+ }
+
/// Creates a default admin user for the system
///
/// @param args the application arguments
diff --git a/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java b/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java
index 94c9ebe..0f046a8 100644
--- a/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java
+++ b/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java
@@ -1,22 +1,29 @@
package org.openpodcastapi.opa.util;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.log4j.Log4j2;
import org.openpodcastapi.opa.security.RefreshTokenRepository;
+import org.slf4j.Logger;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
+import static org.slf4j.LoggerFactory.getLogger;
+
/// A scheduled task to clean up expired refresh tokens
@Component
-@RequiredArgsConstructor
-@Log4j2
public class RefreshTokenCleanup {
+ private static final Logger log = getLogger(RefreshTokenCleanup.class);
private final RefreshTokenRepository repository;
+ /// Required-args constructor
+ ///
+ /// @param repository the refresh token repository for handling refresh token interactions
+ public RefreshTokenCleanup(RefreshTokenRepository repository) {
+ this.repository = repository;
+ }
+
/// Runs a task every hour to clean up expired refresh tokens
@Scheduled(cron = "0 0 * * * ?")
@Transactional
diff --git a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java
index af0f7e3..d16cf41 100644
--- a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java
+++ b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java
@@ -2,31 +2,21 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.openpodcastapi.opa.security.RefreshTokenRepository;
-import org.openpodcastapi.opa.security.TokenService;
+import org.openpodcastapi.opa.user.UserDTO;
import org.openpodcastapi.opa.user.UserEntity;
+import org.openpodcastapi.opa.user.UserMapper;
import org.openpodcastapi.opa.user.UserRepository;
-import org.openpodcastapi.opa.user.UserRoles;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
-import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
-import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
-import java.time.Instant;
-import java.util.Optional;
-import java.util.Set;
import java.util.UUID;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.when;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
@@ -40,51 +30,25 @@
@AutoConfigureRestDocs(outputDir = "target/generated-snippets")
class AuthApiTest {
+ private final String password = "testPassword";
@Autowired
MockMvc mockMvc;
-
- @MockitoBean
+ @Autowired
private BCryptPasswordEncoder passwordEncoder;
-
- @MockitoBean
+ @Autowired
private UserRepository userRepository;
-
- @MockitoBean
- @Qualifier("apiLoginManager")
- private AuthenticationManager authenticationManager;
-
- @MockitoBean
- private RefreshTokenRepository refreshTokenRepository;
-
- @MockitoBean
- private TokenService tokenService;
+ @Autowired
+ private UserMapper userMapper;
+ private UserEntity mockUser;
@BeforeEach
void setup() {
- // Mock the userEntity lookup
- UserEntity mockUserEntity = UserEntity.builder()
- .id(2L)
- .uuid(UUID.randomUUID())
- .email("test@test.test")
- .password("password")
- .username("test_user")
- .createdAt(Instant.now())
- .updatedAt(Instant.now())
- .userRoles(Set.of(UserRoles.USER))
- .build();
-
- // Mock repository behavior for finding user by username
- when(userRepository.findUserByUsername("test_user")).thenReturn(Optional.of(mockUserEntity));
-
- // Mock the refresh token validation to return the mock user
- when(tokenService.validateRefreshToken(anyString(), any(UserEntity.class)))
- .thenReturn(mockUserEntity);
-
- // Mock the access token generation
- when(tokenService.generateAccessToken(any(UserEntity.class))).thenReturn("eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiI2MmJjZjczZC0xNGVjLTRkZmMtOGY5ZS1hMDQ0YjE4YjJiYTUiLCJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzYzODQzMzEwLCJleHAiOjE3NjM4NDQyMTB9.B9aj5DoVpNe6HTxXm8iTHj5XaqFCcR1ZHRZq6xiqY28YvGGStVkPpedDVZfc02-B");
-
- // Mock the refresh token generation
- when(tokenService.generateRefreshToken(any(UserEntity.class))).thenReturn("8be54fc2-70ec-48ef-a8ff-4548fd8932b8e947a7ab-99b5-4cfb-b546-ac37eafa6c98");
+ userRepository.deleteAll();
+ final var mockUserDetails = new UserDTO.CreateUserDTO("user", password, "test@test.test");
+ final var convertedUser = userMapper.toEntity(mockUserDetails);
+ convertedUser.setUuid(UUID.randomUUID());
+ convertedUser.setPassword(passwordEncoder.encode(password));
+ mockUser = userRepository.save(convertedUser);
}
@Test
@@ -92,8 +56,8 @@ void authenticate_and_get_tokens() throws Exception {
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
- { "username": "test_user", "password": "password" }
- """))
+ { "username": "%s", "password": "%s" }
+ """.formatted(mockUser.getUsername(), password)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").exists())
.andExpect(jsonPath("$.refreshToken").exists())
@@ -118,8 +82,8 @@ void refresh_token_flow() throws Exception {
String json = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
- { "username": "test_user", "password": "password" }
- """))
+ { "username": "%s", "password": "%s" }
+ """.formatted(mockUser.getUsername(), password)))
.andReturn()
.getResponse()
.getContentAsString();
@@ -133,7 +97,7 @@ void refresh_token_flow() throws Exception {
"username": "%s",
"refreshToken": "%s"
}
- """.formatted("test_user", refresh)))
+ """.formatted(mockUser.getUsername(), refresh)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").exists())
.andExpect(jsonPath("$.expiresIn").exists())
diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java
similarity index 75%
rename from src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java
rename to src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java
index c488801..348d8f5 100644
--- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java
+++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java
@@ -1,38 +1,29 @@
package org.openpodcastapi.opa.subscriptions;
-import jakarta.persistence.EntityNotFoundException;
-import lombok.NonNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openpodcastapi.opa.security.TokenService;
import org.openpodcastapi.opa.subscription.SubscriptionDTO;
+import org.openpodcastapi.opa.subscription.SubscriptionRepository;
import org.openpodcastapi.opa.subscription.SubscriptionService;
+import org.openpodcastapi.opa.user.UserDTO;
import org.openpodcastapi.opa.user.UserEntity;
+import org.openpodcastapi.opa.user.UserMapper;
import org.openpodcastapi.opa.user.UserRepository;
-import org.openpodcastapi.opa.user.UserRoles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageImpl;
-import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
-import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
-import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.json.JsonMapper;
-import java.time.Instant;
import java.util.List;
-import java.util.Optional;
-import java.util.Set;
import java.util.UUID;
-import static org.mockito.ArgumentMatchers.*;
-import static org.mockito.Mockito.when;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
@@ -48,7 +39,7 @@
@ActiveProfiles("test")
@AutoConfigureMockMvc
@AutoConfigureRestDocs(outputDir = "target/generated-snippets")
-class SubscriptionEntityRestControllerTest {
+class SubscriptionRestControllerTest {
@Autowired
private MockMvc mockMvc;
@@ -58,32 +49,32 @@ class SubscriptionEntityRestControllerTest {
@Autowired
private TokenService tokenService;
- @MockitoBean
+ @Autowired
private UserRepository userRepository;
- @MockitoBean
+ @Autowired
+ private UserMapper userMapper;
+
+ @Autowired
private SubscriptionService subscriptionService;
- private String accessToken;
+ @Autowired
+ private SubscriptionRepository subscriptionRepository;
+
+ @Autowired
+ private BCryptPasswordEncoder passwordEncoder;
private UserEntity mockUser;
@BeforeEach
void setup() {
- mockUser = UserEntity
- .builder()
- .id(1L)
- .uuid(UUID.randomUUID())
- .username("user")
- .email("user@test.test")
- .userRoles(Set.of(UserRoles.USER))
- .createdAt(Instant.now())
- .updatedAt(Instant.now())
- .build();
-
- when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser));
-
- accessToken = tokenService.generateAccessToken(mockUser);
+ userRepository.deleteAll();
+ subscriptionRepository.deleteAll();
+ final var mockUserDetails = new UserDTO.CreateUserDTO("user", "testPassword", "test@test.test");
+ final var convertedUser = userMapper.toEntity(mockUserDetails);
+ convertedUser.setUuid(UUID.randomUUID());
+ convertedUser.setPassword(passwordEncoder.encode("testPassword"));
+ mockUser = userRepository.save(convertedUser);
}
@Test
@@ -95,14 +86,16 @@ void getAllSubscriptionsForAnonymous_shouldReturn401() throws Exception {
}
@Test
- @WithMockUser(username = "user")
void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception {
- SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), null);
- SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), null);
- Page page = new PageImpl<>(List.of(sub1, sub2));
+ final var accessToken = tokenService.generateAccessToken(mockUser);
+
+ final var uuid1 = UUID.randomUUID();
+ final var uuid2 = UUID.randomUUID();
+
+ final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid1.toString(), "test.com/feed1");
+ final var sub2DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid2.toString(), "test.com/feed2");
- when(subscriptionService.getAllActiveSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class)))
- .thenReturn(page);
+ subscriptionService.addSubscriptions(List.of(sub1DTO, sub2DTO), mockUser.getId());
mockMvc.perform(get("/api/v1/subscriptions")
.header("Authorization", "Bearer " + accessToken)
@@ -141,14 +134,18 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception {
}
@Test
- @WithMockUser(username = "user")
void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws Exception {
- SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), null);
- SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), Instant.now());
- Page page = new PageImpl<>(List.of(sub1, sub2));
+ final var accessToken = tokenService.generateAccessToken(mockUser);
+
+ final var uuid1 = UUID.randomUUID();
+ final var uuid2 = UUID.randomUUID();
+
+ final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid1.toString(), "test.com/feed1");
+ final var sub2DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid2.toString(), "test.com/feed2");
+
+ subscriptionService.addSubscriptions(List.of(sub1DTO, sub2DTO), mockUser.getId());
- when(subscriptionService.getAllSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class)))
- .thenReturn(page);
+ subscriptionService.unsubscribeUserFromFeed(uuid2, mockUser.getId());
mockMvc.perform(get("/api/v1/subscriptions")
.header("Authorization", "Bearer " + accessToken)
@@ -168,10 +165,8 @@ void getSubscriptionByUuidForAnonymous_shouldReturnUnauthorized() throws Excepti
}
@Test
- @WithMockUser(username = "test")
void getNonexistentSubscription_shouldReturnNotFound() throws Exception {
- when(subscriptionService.getUserSubscriptionBySubscriptionUuid(any(UUID.class), anyLong()))
- .thenThrow(new EntityNotFoundException());
+ final var accessToken = tokenService.generateAccessToken(mockUser);
mockMvc.perform(get("/api/v1/subscriptions/{uuid}", UUID.randomUUID())
.header("Authorization", "Bearer " + accessToken))
@@ -179,15 +174,16 @@ void getNonexistentSubscription_shouldReturnNotFound() throws Exception {
}
@Test
- @WithMockUser(username = "user")
void getSubscriptionByUuid_shouldReturnSubscription() throws Exception {
- UUID subscriptionUuid = UUID.randomUUID();
+ final var accessToken = tokenService.generateAccessToken(mockUser);
- SubscriptionDTO.UserSubscriptionDTO sub = new SubscriptionDTO.UserSubscriptionDTO(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), null);
- when(subscriptionService.getUserSubscriptionBySubscriptionUuid(subscriptionUuid, mockUser.getId()))
- .thenReturn(sub);
+ final var uuid1 = UUID.randomUUID();
- mockMvc.perform(get("/api/v1/subscriptions/{uuid}", subscriptionUuid)
+ final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid1.toString(), "test.com/feed1");
+
+ subscriptionService.addSubscriptions(List.of(sub1DTO), mockUser.getId());
+
+ mockMvc.perform(get("/api/v1/subscriptions/{uuid}", uuid1)
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andDo(document("subscription-get",
@@ -217,8 +213,9 @@ void createUserSubscriptionWithAnonymousUser_shouldReturnUnauthorized() throws E
}
@Test
- @WithMockUser(username = "user")
void createUserSubscriptionsWithoutBody_shouldReturnBadRequest() throws Exception {
+ final var accessToken = tokenService.generateAccessToken(mockUser);
+
mockMvc.perform(post("/api/v1/subscriptions")
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON))
@@ -226,28 +223,19 @@ void createUserSubscriptionsWithoutBody_shouldReturnBadRequest() throws Exceptio
}
@Test
- @WithMockUser(username = "user")
void createUserSubscriptions_shouldReturnMixedResponse() throws Exception {
- final Instant timestamp = Instant.now();
+ final var accessToken = tokenService.generateAccessToken(mockUser);
- final UUID goodFeedUUID = UUID.randomUUID();
- final String BAD_UUID = "62ad30ce-aac0-4f0a-a811";
+ final var uuid1 = UUID.randomUUID();
+ final var BAD_UUID = "62ad30ce-aac0-4f0a-a811";
- SubscriptionDTO.SubscriptionCreateDTO dto1 = new SubscriptionDTO.SubscriptionCreateDTO(goodFeedUUID.toString(), "test.com/feed1");
- SubscriptionDTO.SubscriptionCreateDTO dto2 = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2");
-
- SubscriptionDTO.BulkSubscriptionResponseDTO response = new SubscriptionDTO.BulkSubscriptionResponseDTO(
- List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, null)),
- List.of(new SubscriptionDTO.SubscriptionFailureDTO(BAD_UUID, "test.com/feed2", "invalid UUID format"))
- );
-
- when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId())))
- .thenReturn(response);
+ final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid1.toString(), "test.com/feed1");
+ final var sub2DTO = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed1");
mockMvc.perform(post("/api/v1/subscriptions")
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
- .content(jsonMapper.writeValueAsString(List.of(dto1, dto2))))
+ .content(jsonMapper.writeValueAsString(List.of(sub1DTO, sub2DTO))))
.andExpect(status().isMultiStatus())
.andDo(document("subscriptions-bulk-create-mixed",
preprocessRequest(prettyPrint()),
@@ -275,20 +263,10 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception {
}
@Test
- @WithMockUser(username = "user")
void createUserSubscription_shouldReturnSuccess() throws Exception {
- final UUID goodFeedUUID = UUID.randomUUID();
- final Instant timestamp = Instant.now();
-
- SubscriptionDTO.SubscriptionCreateDTO dto = new SubscriptionDTO.SubscriptionCreateDTO(goodFeedUUID.toString(), "test.com/feed1");
+ final var accessToken = tokenService.generateAccessToken(mockUser);
- final var response = new SubscriptionDTO.BulkSubscriptionResponseDTO(
- List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, null)),
- List.of()
- );
-
- when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId())))
- .thenReturn(response);
+ final var dto = new SubscriptionDTO.SubscriptionCreateDTO(UUID.randomUUID().toString(), "test.com/feed1");
mockMvc.perform(post("/api/v1/subscriptions")
.header("Authorization", "Bearer " + accessToken)
@@ -316,19 +294,10 @@ void createUserSubscription_shouldReturnSuccess() throws Exception {
}
@Test
- @WithMockUser(username = "user")
void createUserSubscription_shouldReturnFailure() throws Exception {
- final String BAD_UUID = "62ad30ce-aac0-4f0a-a811";
-
- final var dto = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2");
-
- final var response = new SubscriptionDTO.BulkSubscriptionResponseDTO(
- List.of(),
- List.of(new SubscriptionDTO.SubscriptionFailureDTO(BAD_UUID, "test.com/feed2", "invalid UUID format"))
- );
+ final var accessToken = tokenService.generateAccessToken(mockUser);
- when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId())))
- .thenReturn(response);
+ final var dto = new SubscriptionDTO.SubscriptionCreateDTO("62ad30ce-aac0-4f0a-a811", "test.com/feed2");
mockMvc.perform(post("/api/v1/subscriptions")
.header("Authorization", "Bearer " + accessToken)
@@ -358,10 +327,8 @@ void unsubscribingWithAnonymousUser_shouldReturnUnauthorized() throws Exception
}
@Test
- @WithMockUser(username = "user")
void unsubscribingNonexistentEntity_shouldReturnNotFound() throws Exception {
- when(subscriptionService.unsubscribeUserFromFeed(any(UUID.class), anyLong()))
- .thenThrow(new EntityNotFoundException());
+ final var accessToken = tokenService.generateAccessToken(mockUser);
mockMvc.perform(post("/api/v1/subscriptions/{uuid}/unsubscribe", UUID.randomUUID())
.header("Authorization", "Bearer " + accessToken)
@@ -370,21 +337,13 @@ void unsubscribingNonexistentEntity_shouldReturnNotFound() throws Exception {
}
@Test
- @WithMockUser(username = "user")
void unsubscribe_shouldReturnUpdatedSubscription() throws Exception {
- final var subscriptionUuid = UUID.randomUUID();
- final var timestamp = Instant.now();
+ final var accessToken = tokenService.generateAccessToken(mockUser);
- SubscriptionDTO.UserSubscriptionDTO updatedSubscription = new SubscriptionDTO.UserSubscriptionDTO(
- subscriptionUuid,
- "test.com/feed1",
- timestamp,
- timestamp,
- timestamp
- );
+ final var subscriptionUuid = UUID.randomUUID();
+ final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(subscriptionUuid.toString(), "test.com/feed1");
- when(subscriptionService.unsubscribeUserFromFeed(subscriptionUuid, mockUser.getId()))
- .thenReturn(updatedSubscription);
+ subscriptionService.addSubscriptions(List.of(sub1DTO), mockUser.getId());
// Act & Assert
mockMvc.perform(post("/api/v1/subscriptions/{uuid}/unsubscribe", subscriptionUuid)
@@ -393,7 +352,7 @@ void unsubscribe_shouldReturnUpdatedSubscription() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.uuid").value(subscriptionUuid.toString()))
.andExpect(jsonPath("$.feedUrl").value("test.com/feed1"))
- .andExpect(jsonPath("$.unsubscribedAt").value(timestamp.toString()))
+ .andExpect(jsonPath("$.unsubscribedAt").exists())
.andDo(document("subscription-unsubscribe",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java
similarity index 59%
rename from src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java
rename to src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java
index 5ef50ef..821d79c 100644
--- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java
+++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java
@@ -9,14 +9,13 @@
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.junit.jupiter.SpringExtension;
-import java.time.Instant;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = UserSubscriptionMapperImpl.class)
-class UserSubscriptionEntityMapperTest {
+class UserSubscriptionMapperTest {
@Autowired
private UserSubscriptionMapper mapper;
@@ -26,30 +25,12 @@ class UserSubscriptionEntityMapperTest {
/// Tests that a [UserSubscriptionEntity] entity maps to a [SubscriptionDTO.UserSubscriptionDTO] representation
@Test
void testToDto() {
- final Instant timestamp = Instant.now();
- final UUID uuid = UUID.randomUUID();
- UserEntity userEntity = UserEntity.builder()
- .uuid(UUID.randomUUID())
- .username("test")
- .email("test@test.test")
- .createdAt(timestamp)
- .updatedAt(timestamp)
- .build();
+ final var uuid = UUID.randomUUID();
+ final var userEntity = new UserEntity(1L, UUID.randomUUID(), "test", "test@test.test");
- SubscriptionEntity subscriptionEntity = SubscriptionEntity.builder()
- .uuid(UUID.randomUUID())
- .feedUrl("test.com/feed1")
- .createdAt(timestamp)
- .updatedAt(timestamp)
- .build();
+ final var subscriptionEntity = new SubscriptionEntity(UUID.randomUUID(), "test.com/feed1");
- UserSubscriptionEntity userSubscriptionEntity = UserSubscriptionEntity.builder()
- .uuid(uuid)
- .user(userEntity)
- .subscription(subscriptionEntity)
- .createdAt(timestamp)
- .updatedAt(timestamp)
- .build();
+ final var userSubscriptionEntity = new UserSubscriptionEntity(uuid, userEntity, subscriptionEntity);
SubscriptionDTO.UserSubscriptionDTO dto = mapper.toDto(userSubscriptionEntity);
assertNotNull(dto);
diff --git a/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/user/UserMapperTest.java
similarity index 86%
rename from src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java
rename to src/test/java/org/openpodcastapi/opa/user/UserMapperTest.java
index 8e85b97..9704283 100644
--- a/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java
+++ b/src/test/java/org/openpodcastapi/opa/user/UserMapperTest.java
@@ -14,7 +14,7 @@
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = UserMapperImpl.class)
-class UserEntityMapperTest {
+class UserMapperTest {
@Autowired
private UserMapper mapper;
@@ -26,13 +26,7 @@ class UserEntityMapperTest {
void testToDto() {
final Instant timestamp = Instant.now();
final UUID uuid = UUID.randomUUID();
- UserEntity userEntity = UserEntity.builder()
- .uuid(uuid)
- .username("test")
- .email("test@test.test")
- .createdAt(timestamp)
- .updatedAt(timestamp)
- .build();
+ final var userEntity = new UserEntity(1L, uuid, "test", "", "test@test.test", timestamp, timestamp);
UserDTO.UserResponseDTO dto = mapper.toDto(userEntity);
assertNotNull(dto);
diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java
index c3d5b29..3d68419 100644
--- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java
+++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java
@@ -1,30 +1,21 @@
package org.openpodcastapi.opa.user;
-import lombok.NonNull;
-import lombok.extern.log4j.Log4j2;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openpodcastapi.opa.security.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
-import org.springframework.data.domain.PageImpl;
-import org.springframework.data.domain.PageRequest;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
-import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
-import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
-import java.time.Instant;
-import java.util.List;
-import java.util.Optional;
import java.util.Set;
import java.util.UUID;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
@@ -40,20 +31,33 @@
@ActiveProfiles("test")
@AutoConfigureMockMvc
@AutoConfigureRestDocs(outputDir = "target/generated-snippets")
-@Log4j2
class UserRestControllerTest {
-
@Autowired
private MockMvc mockMvc;
@Autowired
private TokenService tokenService;
- @MockitoBean
+ @Autowired
private UserRepository userRepository;
- @MockitoBean
- private UserService userService;
+ @Autowired
+ private BCryptPasswordEncoder passwordEncoder;
+
+ @Autowired
+ private UserMapper userMapper;
+
+ private UserEntity mockUser;
+
+ @BeforeEach
+ void setup() {
+ userRepository.deleteAll();
+ final var mockUserDetails = new UserDTO.CreateUserDTO("user", "testPassword", "test@test.test");
+ final var convertedUser = userMapper.toEntity(mockUserDetails);
+ convertedUser.setUuid(UUID.randomUUID());
+ convertedUser.setPassword(passwordEncoder.encode("testPassword"));
+ mockUser = userRepository.save(convertedUser);
+ }
@Test
void getAllUsers_shouldReturn401_forAnonymousUser() throws Exception {
@@ -63,44 +67,19 @@ void getAllUsers_shouldReturn401_forAnonymousUser() throws Exception {
}
@Test
- @WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
void getAllUsers_shouldReturn200_andList() throws Exception {
- UserEntity mockUser = UserEntity
- .builder()
- .id(1L)
- .uuid(UUID.randomUUID())
- .username("admin")
- .email("admin@test.test")
- .userRoles(Set.of(UserRoles.USER, UserRoles.ADMIN))
- .createdAt(Instant.now())
- .updatedAt(Instant.now())
- .build();
-
- when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser));
+ mockUser.setUserRoles(Set.of(UserRoles.USER, UserRoles.ADMIN));
+ mockUser = userRepository.save(mockUser);
- String accessToken = tokenService.generateAccessToken(mockUser);
-
- final Instant createdDate = Instant.now();
-
- final UserDTO.UserResponseDTO user1 = new UserDTO.UserResponseDTO(
- UUID.randomUUID(),
- "alice",
- "alice@test.com",
- createdDate,
- createdDate
- );
+ final var accessToken = tokenService.generateAccessToken(mockUser);
- final UserDTO.UserResponseDTO user2 = new UserDTO.UserResponseDTO(
- UUID.randomUUID(),
- "bob",
- "bob@test.com",
- createdDate,
- createdDate
- );
-
- // Mock the service call to return users
- PageImpl page = new PageImpl<>(List.of(user1, user2), PageRequest.of(0, 2), 2);
- when(userService.getAllUsers(any())).thenReturn(page);
+ // Mock a second user
+ final var uuid = UUID.randomUUID();
+ final var dto = new UserDTO.CreateUserDTO("bob", "testPassword", "bob@test.test");
+ final var convertedUser = userMapper.toEntity(dto);
+ convertedUser.setUuid(uuid);
+ convertedUser.setPassword(passwordEncoder.encode("testPassword"));
+ userRepository.save(convertedUser);
// Perform the test for the admin role
mockMvc.perform(get("/api/v1/users")
@@ -137,21 +116,7 @@ void getAllUsers_shouldReturn200_andList() throws Exception {
}
@Test
- @WithMockUser(username = "user", roles = "USER")
void getAllUsers_shouldReturn403_forUserRole() throws Exception {
- UserEntity mockUser = UserEntity
- .builder()
- .id(1L)
- .uuid(UUID.randomUUID())
- .username("user")
- .email("user@test.test")
- .userRoles(Set.of(UserRoles.USER))
- .createdAt(Instant.now())
- .updatedAt(Instant.now())
- .build();
-
- when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser));
-
String accessToken = tokenService.generateAccessToken(mockUser);
mockMvc.perform(get("/api/v1/users")
@@ -160,7 +125,7 @@ void getAllUsers_shouldReturn403_forUserRole() throws Exception {
.param("page", "0")
.param("size", "20"))
.andExpect(status().isForbidden())
- .andDo(document("users-list",
+ .andDo(document("users-list-unsuccessful",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
queryParameters(