diff --git a/src/docs/auth.adoc b/src/docs/auth.adoc index 788ef79..d1546f6 100644 --- a/src/docs/auth.adoc +++ b/src/docs/auth.adoc @@ -4,23 +4,21 @@ The `auth` endpoint exposes operations for authenticating against the API. -[[actions-auth]] -== Actions - [[actions-login]] -=== Log in +== Log in [source,httprequest] ---- POST /api/auth/login ---- -Authenticates a user with `username` and `password`. These values must match the values of the user's account. +Authenticates a user with `username` and `password`. +These values must match the values of the user's account. operation::auth-token[snippets='request-fields,curl-request,response-fields,http-response'] [[actions-refresh]] -=== Request a new access token +== Request a new access token You can request a new access token by passing a valid refresh value to the API. diff --git a/src/docs/index.adoc b/src/docs/index.adoc index e139a49..3759cd2 100644 --- a/src/docs/index.adoc +++ b/src/docs/index.adoc @@ -3,10 +3,11 @@ :icons: font :source-highlighter: highlightjs :toc: right -:toclevels: 2 +:toclevels: 1 :sectlinks: This server implements the https://openpodcastapi.org[Open Podcast API]. +include::auth.adoc[] include::users.adoc[] include::subscriptions.adoc[] \ No newline at end of file diff --git a/src/docs/subscriptions.adoc b/src/docs/subscriptions.adoc index 1a9fcf8..a0aba8b 100644 --- a/src/docs/subscriptions.adoc +++ b/src/docs/subscriptions.adoc @@ -2,27 +2,26 @@ :doctype: book :sectlinks: -The `subscriptions` endpoint exposes operations taken on subscriptions. A subscription represents two things: +The `subscriptions` endpoint exposes operations taken on subscriptions. +A subscriptionEntity represents two things: 1. A podcast feed 2. The relationship between a user and a podcast feed -[[actions-subscriptions]] -== Actions - [source,httprequest] ---- POST /api/v1/users ---- [[actions-subscriptions-create]] -=== Create subscriptions +== Create subscriptions -When a user adds a subscription to the system, a corresponding `subscription` object is fetched or created depending on whether a matching subscription is present. A link is then created between the user and the subscription. +When a user adds a subscription to the system, a corresponding `subscriptionEntity` object is fetched or created depending on whether a matching subscriptionEntity is present. +A link is then created between the user and the subscriptionEntity. -operation::subscriptions-bulk-create-mixed[snippets='request-fields,curl-request,response-fields,http-response'] +operation::subscriptions-bulk-create-mixed[snippets='request-headers,request-fields,curl-request,response-fields,http-response'] -==== Responses +=== Responses If all feeds are valid and no problems are encountered, the server responds with an array of `success` objects and an empty array of `failure` objects. @@ -37,26 +36,30 @@ If the server receives a mix of responses, both arrays are populated. include::{snippets}/subscriptions-bulk-create-mixed/http-response.adoc[] [[actions-subscriptions-list]] -=== List subscriptions +== List subscriptions -When a user fetches a list of subscriptions, their own subscriptions are returned. The subscriptions of other users are not returned. +When a user fetches a list of subscriptions, their own subscriptions are returned. +The subscriptions of other users are not returned. -operation::subscriptions-list[snippets='query-parameters,curl-request,response-fields,http-response'] +operation::subscriptions-list[snippets='request-headers,query-parameters,curl-request,response-fields,http-response'] -==== Include unsubscribed +=== Include unsubscribed operation::subscriptions-list-with-unsubscribed[snippets='curl-request,http-response'] -[[actions-subscription-fetch]] -=== Fetch a single subscription +[[actions-subscriptionEntity-fetch]] +== Fetch a single subscriptionEntity -Returns the details of a single subscription for the authenticated user. Returns `404` if the user has no subscription entry for the feed in question. +Returns the details of a single subscriptionEntity for the authenticated user. +Returns `404` if the user has no subscriptionEntity entry for the feed in question. -operation::subscription-get[snippets='path-parameters,curl-request,response-fields,http-response'] +operation::subscriptionEntity-get[snippets='request-headers,path-parameters,curl-request,response-fields,http-response'] -[[actions-subscription-update]] -=== Unsubscribe from a feed +[[actions-subscriptionEntity-update]] +== Unsubscribe from a feed -Unsubscribes the authenticated user from a feed. This action updates the user subscription record to mark the subscription as inactive. It does not delete the subscription record. +Unsubscribes the authenticated user from a feed. +This action updates the user subscriptionEntity record to mark the subscriptionEntity as inactive. +It does not delete the subscriptionEntity record. -operation::subscription-unsubscribe[snippets='path-parameters,curl-request,response-fields,http-response'] +operation::subscriptionEntity-unsubscribe[snippets='request-headers,path-parameters,curl-request,response-fields,http-response'] diff --git a/src/docs/users.adoc b/src/docs/users.adoc index 669ae13..0a49d27 100644 --- a/src/docs/users.adoc +++ b/src/docs/users.adoc @@ -2,31 +2,11 @@ :doctype: book :sectlinks: -The `users` endpoint exposes operations taken on user accounts. Users may update and delete their own user record, but only admins may update and alter the records of other users. - -[[actions-users]] -== Actions - -[[actions-users-create]] -=== Create a user - -[source,httprequest] ----- -POST /api/v1/users ----- - -Creates a new user in the system. - -operation::users-create[snippets='request-fields,curl-request,response-fields,http-response'] - -==== Invalid fields - -Passing an invalid field (such as an improperly formatted email address) throws a validation error. - -operation::users-create-bad-request[snippets='curl-request,http-response'] +The `users` endpoint exposes operations taken on user accounts. +Users may update and delete their own user record, but only admins may update and alter the records of other users. [[actions-users-get]] -=== Get all users +== Get all users [source,httprequest] ---- @@ -34,5 +14,6 @@ GET /api/v1/users ---- Fetches a paginated list of users from the system. +This action is restricted to users with `ADMIN` permissions. -operation::users-list[snippets='curl-request,response-fields,http-response'] +operation::users-list[snippets='request-headers,query-parameters,curl-request,response-fields,http-response'] diff --git a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java index 82b4fd6..6bf74aa 100644 --- a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java +++ b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java @@ -21,8 +21,9 @@ public class GlobalExceptionHandler { @ExceptionHandler(EntityNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) - public ResponseEntity handleEntityNotFoundException(EntityNotFoundException e) { - return ResponseEntity.badRequest().body(e.getMessage()); + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException error) { + log.debug("{}", error.getMessage()); + return ResponseEntity.notFound().build(); } @ExceptionHandler(DataIntegrityViolationException.class) diff --git a/src/main/java/org/openpodcastapi/opa/auth/DTOs.java b/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java similarity index 77% rename from src/main/java/org/openpodcastapi/opa/auth/DTOs.java rename to src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java index 5acf7f8..2d70c1a 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/DTOs.java +++ b/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java @@ -3,9 +3,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotNull; -/// All DTOs for auth methods -public class DTOs { - /// A DTO representing an API login request +/// All data transfer objects for auth methods +public class AuthDTO { + /// A DTO representing an api login request /// /// @param username the user's username /// @param password the user's password @@ -15,7 +15,7 @@ public record LoginRequest( ) { } - /// A DTO representing a successful API authentication attempt + /// A DTO representing a successful api authentication attempt /// /// @param accessToken the access token to be used to authenticate /// @param expiresIn the TTL of the access token (in seconds) @@ -46,4 +46,14 @@ public record RefreshTokenResponse( @JsonProperty(value = "expiresIn", required = true) @NotNull String expiresIn ) { } + + /// Displays an auth error + /// + /// @param error the error message + /// @param message an additional description of the error + public record ErrorMessageDTO( + @JsonProperty(value = "error", required = true) @NotNull String error, + @JsonProperty(value = "message", required = true) @NotNull String message + ) { + } } diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java index 0b37643..b60352f 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java @@ -1,7 +1,9 @@ package org.openpodcastapi.opa.auth; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; @@ -10,7 +12,9 @@ import java.io.IOException; @Component +@RequiredArgsConstructor public class JwtAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; @Override public void handle(HttpServletRequest request, @@ -23,13 +27,8 @@ public void handle(HttpServletRequest request, // Set content type to JSON response.setContentType("application/json"); - String body = """ - { - "error": "Forbidden", - "message": "You do not have permission to access this resource." - } - """; + AuthDTO.ErrorMessageDTO message = new AuthDTO.ErrorMessageDTO("Forbidden", "You do not have permission to access this resource"); - response.getWriter().write(body); + response.getWriter().write(objectMapper.writeValueAsString(message)); } } diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java index 438de2b..d829df4 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java @@ -1,8 +1,9 @@ package org.openpodcastapi.opa.auth; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.log4j.Log4j2; +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -11,8 +12,10 @@ import java.io.IOException; @Component -@Log4j2 +@RequiredArgsConstructor public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + /// Returns a 401 when a request is made without a valid bearer token @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { @@ -22,14 +25,8 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A // Set content type to JSON response.setContentType("application/json"); - // Return a simple JSON error message - String body = """ - { - "error": "Unauthorized", - "message": "You need to log in to access this resource." - } - """; + AuthDTO.ErrorMessageDTO message = new AuthDTO.ErrorMessageDTO("Access denied", "You need to log in to access this resource"); - response.getWriter().write(body); + response.getWriter().write(objectMapper.writeValueAsString(message)); } } diff --git a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationFilter.java similarity index 80% rename from src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java rename to src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationFilter.java index 4b02aea..555d489 100644 --- a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.config; +package org.openpodcastapi.opa.auth; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; @@ -12,8 +12,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.service.CustomUserDetails; -import org.openpodcastapi.opa.user.model.User; -import org.openpodcastapi.opa.user.repository.UserRepository; +import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -37,17 +37,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { /// Returns an authentication token for a user /// - /// @param user the [User] to fetch a token for + /// @param userEntity the [UserEntity] to fetch a token for /// @return a generated token /// @throws EntityNotFoundException if no matching user is found - private static UsernamePasswordAuthenticationToken getUsernamePasswordAuthenticationToken(User user) throws EntityNotFoundException { + private static UsernamePasswordAuthenticationToken getUsernamePasswordAuthenticationToken(UserEntity userEntity) throws EntityNotFoundException { // Create a new CustomUserDetails entity with the fetched user CustomUserDetails userDetails = - new CustomUserDetails(user.getId(), - user.getUuid(), - user.getUsername(), - user.getPassword(), - user.getUserRoles()); + new CustomUserDetails(userEntity.getId(), + userEntity.getUuid(), + userEntity.getUsername(), + userEntity.getPassword(), + userEntity.getUserRoles()); // Return a token for the user return new UsernamePasswordAuthenticationToken( @@ -68,12 +68,6 @@ private static UsernamePasswordAuthenticationToken getUsernamePasswordAuthentica protected void doFilterInternal(HttpServletRequest req, @Nonnull HttpServletResponse res, @Nonnull FilterChain chain) throws ServletException, IOException { - // Don't apply the check on the auth endpoints - if (req.getRequestURI().startsWith("/api/auth/")) { - chain.doFilter(req, res); - return; - } - String header = req.getHeader(HttpHeaders.AUTHORIZATION); SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); @@ -99,10 +93,10 @@ protected void doFilterInternal(HttpServletRequest req, @Nonnull HttpServletResp UUID parsedUuid = UUID.fromString(userUuid); // Fetch the matching user - User user = repository.getUserByUuid(parsedUuid).orElseThrow(() -> new EntityNotFoundException("No matching user found")); + UserEntity userEntity = repository.getUserByUuid(parsedUuid).orElseThrow(() -> new EntityNotFoundException("No matching user found")); // Create a user - UsernamePasswordAuthenticationToken authentication = getUsernamePasswordAuthenticationToken(user); + UsernamePasswordAuthenticationToken authentication = getUsernamePasswordAuthenticationToken(userEntity); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { diff --git a/src/main/java/org/openpodcastapi/opa/config/JwtService.java b/src/main/java/org/openpodcastapi/opa/config/JwtService.java deleted file mode 100644 index 43ff4d0..0000000 --- a/src/main/java/org/openpodcastapi/opa/config/JwtService.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.openpodcastapi.opa.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -@Service -public class JwtService { - @Value("${jwt.ttl}") - private String jwtExpiration; - - public long getExpirationTime() { - return Long.parseLong(jwtExpiration); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java index f916d91..07b2ae9 100644 --- a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.openpodcastapi.opa.auth.JwtAccessDeniedHandler; import org.openpodcastapi.opa.auth.JwtAuthenticationEntryPoint; +import org.openpodcastapi.opa.auth.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -25,14 +26,32 @@ public class SecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final String[] publicPages = { + "/", + "/login", + "/logout-confirm", + "/register", + "/docs", + "/docs/**", + "/css/**", + "/js/**", + "/images/**", + "/favicon.ico", + }; + + private final String[] publicEndpoints = { + "/api/auth/**" + }; + @Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")) + .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**", "/docs", "/docs/**")) .sessionManagement(sm -> sm .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Stateless session .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/login", "/logout-confirm", "/register", "/docs", "/css/**", "/js/**", "/images/**", "/favicon.ico", "/api/auth/login", "/api/auth/refresh").permitAll() + .requestMatchers(publicPages).permitAll() + .requestMatchers(publicEndpoints).permitAll() .requestMatchers("/api/v1/**").authenticated() .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/org/openpodcastapi/opa/config/TemplateConfig.java b/src/main/java/org/openpodcastapi/opa/config/TemplateConfig.java deleted file mode 100644 index 6e442e4..0000000 --- a/src/main/java/org/openpodcastapi/opa/config/TemplateConfig.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.openpodcastapi.opa.config; - -import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class TemplateConfig { - @Bean - public LayoutDialect layoutDialect() { - return new LayoutDialect(); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java index e98c3f3..69e75b8 100644 --- a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java @@ -1,5 +1,7 @@ package org.openpodcastapi.opa.config; +import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -18,6 +20,11 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { registry .addResourceHandler("/docs/**") - .addResourceLocations("classpath:/docs/"); + .addResourceLocations("classpath:/static/docs/"); + } + + @Bean + public LayoutDialect layoutDialect() { + return new LayoutDialect(); } } diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java similarity index 58% rename from src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java rename to src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java index 909dc71..e59eba8 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java @@ -1,13 +1,13 @@ -package org.openpodcastapi.opa.auth; +package org.openpodcastapi.opa.controllers.api; import jakarta.persistence.EntityNotFoundException; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.config.JwtService; +import org.openpodcastapi.opa.auth.AuthDTO; import org.openpodcastapi.opa.security.TokenService; -import org.openpodcastapi.opa.user.model.User; -import org.openpodcastapi.opa.user.repository.UserRepository; +import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserRepository; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -20,15 +20,14 @@ @RestController @RequiredArgsConstructor @Log4j2 -public class ApiAuthController { - - private final JwtService jwtService; +public class AuthController { private final TokenService tokenService; private final UserRepository userRepository; private final AuthenticationManager authenticationManager; + // === Login endpoint === @PostMapping("/api/auth/login") - public ResponseEntity login(@RequestBody @NotNull DTOs.LoginRequest loginRequest) { + public ResponseEntity login(@RequestBody @NotNull AuthDTO.LoginRequest loginRequest) { // Set the authentication using the provided details Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password()) @@ -38,30 +37,31 @@ public ResponseEntity login(@RequestBody @NotNull DTO SecurityContextHolder.getContext().setAuthentication(authentication); // Fetch the user record from the database - User user = userRepository.findByUsername(loginRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + loginRequest.username() + " found")); + UserEntity userEntity = userRepository.findByUsername(loginRequest.username()).orElseThrow(() -> new EntityNotFoundException("No userEntity with username " + loginRequest.username() + " found")); // Generate the access and refresh tokens for the user - String accessToken = tokenService.generateAccessToken(user); - String refreshToken = tokenService.generateRefreshToken(user); + String accessToken = tokenService.generateAccessToken(userEntity); + String refreshToken = tokenService.generateRefreshToken(userEntity); // Format the tokens and expiration time into a DTO - DTOs.LoginSuccessResponse response = new DTOs.LoginSuccessResponse(accessToken, refreshToken, String.valueOf(jwtService.getExpirationTime())); + AuthDTO.LoginSuccessResponse response = new AuthDTO.LoginSuccessResponse(accessToken, refreshToken, String.valueOf(tokenService.getExpirationTime())); return ResponseEntity.ok(response); } + // === Refresh token endpoint === @PostMapping("/api/auth/refresh") - public ResponseEntity getRefreshToken(@RequestBody @NotNull DTOs.RefreshTokenRequest refreshTokenRequest) { - User targetUser = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); + public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) { + UserEntity targetUserEntity = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); // Validate the existing refresh token - User user = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUser); + UserEntity userEntity = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUserEntity); // Generate new access token - String newAccessToken = tokenService.generateAccessToken(user); + String newAccessToken = tokenService.generateAccessToken(userEntity); // Format the token and expiration time into a DTO - DTOs.RefreshTokenResponse response = new DTOs.RefreshTokenResponse(newAccessToken, String.valueOf(jwtService.getExpirationTime())); + AuthDTO.RefreshTokenResponse response = new AuthDTO.RefreshTokenResponse(newAccessToken, String.valueOf(tokenService.getExpirationTime())); return ResponseEntity.ok(response); } diff --git a/src/main/java/org/openpodcastapi/opa/docs/DocsController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java similarity index 68% rename from src/main/java/org/openpodcastapi/opa/docs/DocsController.java rename to src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java index adef21f..c4360b0 100644 --- a/src/main/java/org/openpodcastapi/opa/docs/DocsController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java @@ -1,16 +1,20 @@ -package org.openpodcastapi.opa.docs; +package org.openpodcastapi.opa.controllers.web; +import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller +@Log4j2 public class DocsController { + // === Docs index page === @GetMapping("/docs") public String docs() { return "forward:/docs/index.html"; } + // === Docs page with trailing slash === @GetMapping("/docs/") public String docsWithSlash() { return "forward:/docs/index.html"; diff --git a/src/main/java/org/openpodcastapi/opa/ui/controller/HomeController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java similarity index 84% rename from src/main/java/org/openpodcastapi/opa/ui/controller/HomeController.java rename to src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java index 23c9f94..ad4f83b 100644 --- a/src/main/java/org/openpodcastapi/opa/ui/controller/HomeController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.ui.controller; +package org.openpodcastapi.opa.controllers.web; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -11,11 +11,13 @@ @Log4j2 public class HomeController { + // === Landing page === @GetMapping("/") public String getLandingPage() { return "landing"; } + // === Authenticated homepage === @GetMapping("/home") public String getHomePage(Authentication auth) { if (auth != null && !auth.isAuthenticated()) { diff --git a/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java similarity index 86% rename from src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java rename to src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java index 8e678c4..4aeccef 100644 --- a/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java @@ -1,10 +1,10 @@ -package org.openpodcastapi.opa.ui.controller; +package org.openpodcastapi.opa.controllers.web; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.user.dto.CreateUserDto; -import org.openpodcastapi.opa.user.service.UserService; +import org.openpodcastapi.opa.user.UserDTO; +import org.openpodcastapi.opa.user.UserService; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -17,7 +17,7 @@ @Controller @Log4j2 @RequiredArgsConstructor -public class UiAuthController { +public class WebAuthController { private static final String USER_REQUEST_ATTRIBUTE = "createUserRequest"; private static final String REGISTER_TEMPLATE = "auth/register"; private final UserService userService; @@ -41,14 +41,14 @@ public String logoutPage() { // === Registration page === @GetMapping("/register") public String getRegister(Model model) { - model.addAttribute(USER_REQUEST_ATTRIBUTE, new CreateUserDto("", "", "")); + model.addAttribute(USER_REQUEST_ATTRIBUTE, new UserDTO.CreateUserDTO("", "", "")); return REGISTER_TEMPLATE; } // === Registration POST handler === @PostMapping("/register") public String processRegistration( - @Valid @ModelAttribute CreateUserDto createUserRequest, + @Valid @ModelAttribute UserDTO.CreateUserDTO createUserRequest, BindingResult result, Model model ) { diff --git a/src/main/java/org/openpodcastapi/opa/helpers/UUIDHelper.java b/src/main/java/org/openpodcastapi/opa/helpers/UUIDHelper.java deleted file mode 100644 index 37bd6b0..0000000 --- a/src/main/java/org/openpodcastapi/opa/helpers/UUIDHelper.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.openpodcastapi.opa.helpers; - -import com.fasterxml.uuid.Generators; -import com.fasterxml.uuid.impl.NameBasedGenerator; - -import java.util.UUID; - -/// A helper class containing methods for validating UUID values -public class UUIDHelper { - - /// The podcasting namespace UUID - static final UUID podcastNamespace = UUID.fromString("ead4c236-bf58-58c6-a2c6-a6b28d128cb6"); - /// A generator that calculates podcast UUID values from feed URLs using the podcast index namespace - static final NameBasedGenerator generator = Generators.nameBasedGenerator(podcastNamespace); - - private UUIDHelper() { - throw new IllegalStateException("Class shouldn't be instantiated"); - } - - /// Sanitizes a feed URL by stripping the scheme and any trailing slashes - /// - /// @param feedUrl the URL to sanitize - /// @return the sanitized URL - public static String sanitizeFeedUrl(String feedUrl) { - if (feedUrl == null || feedUrl.isBlank()) { - throw new IllegalArgumentException("Invalid feed URL passed to function"); - } - - // Reject unsupported schemes (e.g., ftp://) - if (feedUrl.matches("^[a-zA-Z]+://.*") && !feedUrl.startsWith("http://") && !feedUrl.startsWith("https://")) { - throw new IllegalArgumentException("Invalid feed URL passed to function"); - } - - String sanitized = feedUrl.replaceFirst("^(https?://)", "").replaceAll("/+$", ""); - - if (!sanitized.contains(".")) { - throw new IllegalArgumentException("Invalid feed URL passed to function"); - } - - return sanitized; - } - - /// Calculates the UUID of a provided feed URL using Podcast index methodology. - /// - /// See [the Podcast index's documentation](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/tags/guid.md) - /// for more information - /// - /// @param feedUrl the URL of the podcast feed - /// @return the calculated UUID - public static UUID getFeedUUID(String feedUrl) { - final String sanitizedFeedUrl = sanitizeFeedUrl(feedUrl); - return generator.generate(sanitizedFeedUrl); - } - - /// Validates that a supplied subscription UUID has been calculated properly - /// - /// @param feedUrl the URL of the podcast feed - /// @param suppliedUUID the UUID to validate - /// @return whether the UUID values strictly match - public static boolean validateSubscriptionUUID(String feedUrl, UUID suppliedUUID) { - UUID calculatedUUID = getFeedUUID(feedUrl); - return calculatedUUID.equals(suppliedUUID); - } - - /// Validates that a string is a valid UUID - /// - /// @param uuid the UUID string to validate - /// @return `true` if the string is a valid UUID - public static boolean validateUUIDString(String uuid) { - try { - UUID result = UUID.fromString(uuid); - return !result.toString().isEmpty(); - } catch (IllegalArgumentException _) { - return false; - } - } -} diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java similarity index 85% rename from src/main/java/org/openpodcastapi/opa/security/RefreshToken.java rename to src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java index ecbd85d..14b84d2 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java @@ -2,7 +2,7 @@ import jakarta.persistence.*; import lombok.*; -import org.openpodcastapi.opa.user.model.User; +import org.openpodcastapi.opa.user.UserEntity; import java.time.Instant; @@ -12,7 +12,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class RefreshToken { +public class RefreshTokenEntity { @Id @Generated @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -22,7 +22,7 @@ public class RefreshToken { private String tokenHash; @ManyToOne(optional = false, fetch = FetchType.LAZY) - private User user; + private UserEntity user; @Column(nullable = false) private Instant expiresAt; diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java index 56406e4..299b7f6 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java @@ -1,12 +1,12 @@ package org.openpodcastapi.opa.security; -import org.openpodcastapi.opa.user.model.User; +import org.openpodcastapi.opa.user.UserEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @Repository -public interface RefreshTokenRepository extends JpaRepository { - List findAllByUser(User user); +public interface RefreshTokenRepository extends JpaRepository { + List findAllByUser(UserEntity userEntity); } diff --git a/src/main/java/org/openpodcastapi/opa/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java index 3cb7b31..9d20198 100644 --- a/src/main/java/org/openpodcastapi/opa/security/TokenService.java +++ b/src/main/java/org/openpodcastapi/opa/security/TokenService.java @@ -3,7 +3,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; -import org.openpodcastapi.opa.user.model.User; +import org.openpodcastapi.opa.user.UserEntity; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @@ -33,20 +33,28 @@ public class TokenService { @Value("${jwt.refresh-days:7}") private long refreshTokenDays; + @Value("${jwt.ttl}") + private String jwtExpiration; + // The calculated secret key private SecretKey key() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } + /// Returns the expiration time for JWTs + public long getExpirationTime() { + return Long.parseLong(jwtExpiration); + } + /// Generates an access token for a given user /// - /// @param user the [User] to generate a token for + /// @param userEntity the [UserEntity] to generate a token for /// @return the generated token - public String generateAccessToken(User user) { + public String generateAccessToken(UserEntity userEntity) { Instant now = Instant.now(); return Jwts.builder() - .subject(user.getUuid().toString()) - .claim("username", user.getUsername()) + .subject(userEntity.getUuid().toString()) + .claim("username", userEntity.getUsername()) .issuedAt(Date.from(now)) .expiration(Date.from(now.plusSeconds(accessTokenMinutes * 60))) .signWith(key()) @@ -55,15 +63,15 @@ public String generateAccessToken(User user) { /// Generates a refresh token for a given user /// - /// @param user the [User] to generate a refresh token for + /// @param userEntity the [UserEntity] to generate a refresh token for /// @return the generated refresh token - public String generateRefreshToken(User user) { + public String generateRefreshToken(UserEntity userEntity) { String raw = UUID.randomUUID().toString() + UUID.randomUUID(); String hash = passwordEncoder.encode(raw); - RefreshToken token = RefreshToken.builder() + RefreshTokenEntity token = RefreshTokenEntity.builder() .tokenHash(hash) - .user(user) + .user(userEntity) .createdAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(refreshTokenDays * 24 * 3600)) .build(); @@ -74,18 +82,18 @@ public String generateRefreshToken(User user) { /// Validates the refresh token for a user and updates its expiry time /// - /// @param rawToken the raw token to validate - /// @param user the [User] to validate the token for - /// @return the validated [User] - public User validateRefreshToken(String rawToken, User user) { + /// @param rawToken the raw token to validate + /// @param userEntity the [UserEntity] to validate the token for + /// @return the validated [UserEntity] + public UserEntity validateRefreshToken(String rawToken, UserEntity userEntity) { // Only fetch refresh tokens for the requesting user - for (RefreshToken token : repository.findAllByUser(user)) { + for (RefreshTokenEntity token : repository.findAllByUser(userEntity)) { // Check that the raw token and the token hash match and the token is not expired 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)); - RefreshToken updatedToken = repository.save(token); + RefreshTokenEntity 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 daa6b5b..2fdfdce 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 org.openpodcastapi.opa.user.model.UserRoles; +import org.openpodcastapi.opa.user.UserRoles; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java index eb4cdcb..20bcdb6 100644 --- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java +++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java @@ -1,8 +1,8 @@ package org.openpodcastapi.opa.service; import lombok.RequiredArgsConstructor; -import org.openpodcastapi.opa.user.model.User; -import org.openpodcastapi.opa.user.repository.UserRepository; +import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserRepository; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -22,19 +22,19 @@ public class CustomUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String username) { return userRepository.getUserByUsername(username) .map(this::mapToUserDetails) - .orElseThrow(() -> new UsernameNotFoundException("User not found")); + .orElseThrow(() -> new UsernameNotFoundException("UserEntity not found")); } /// Maps a user to a custom user details model /// - /// @param user the user model to map - private CustomUserDetails mapToUserDetails(User user) { + /// @param userEntity the [UserEntity] model to map + private CustomUserDetails mapToUserDetails(UserEntity userEntity) { return new CustomUserDetails( - user.getId(), - user.getUuid(), - user.getUsername(), - user.getPassword(), - user.getUserRoles() + userEntity.getId(), + userEntity.getUuid(), + userEntity.getUsername(), + userEntity.getPassword(), + userEntity.getUserRoles() ); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java new file mode 100644 index 0000000..324c3bd --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java @@ -0,0 +1,94 @@ +package org.openpodcastapi.opa.subscription; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.URL; +import org.hibernate.validator.constraints.UUID; +import org.springframework.data.domain.Page; + +import java.time.Instant; +import java.util.List; + +public class SubscriptionDTO { + /// A DTO representing a new subscription + /// + /// @param feedUrl the URL of the feed + /// @param uuid the UUID of the feed calculated by the client + public record SubscriptionCreateDTO( + @JsonProperty(required = true) @NotNull @UUID String uuid, + @JsonProperty(required = true) @NotNull String feedUrl + ) { + } + + /// A DTO representing a user's subscription to a given feed + /// + /// @param uuid the feed UUID + /// @param feedUrl the feed URL + /// @param createdAt the date at which the subscription link was created + /// @param updatedAt the date at which the subscription link was last updated + /// @param isSubscribed whether the user is currently subscribed to the feed + public record UserSubscriptionDTO( + @JsonProperty(required = true) @UUID java.util.UUID uuid, + @JsonProperty(required = true) @URL String feedUrl, + @JsonProperty(required = true) Instant createdAt, + @JsonProperty(required = true) Instant updatedAt, + @JsonProperty(required = true) Boolean isSubscribed + ) { + } + + /// A DTO representing a bulk subscription creation + /// + /// @param success a list of creation successes + /// @param failure a list of creation failures + public record BulkSubscriptionResponseDTO( + List success, + List failure + ) { + } + + /// A DTO representing a failed subscription creation + /// + /// @param uuid the UUID of the failed subscription + /// @param feedUrl the feed URL of the failed subscription + /// @param message the error message explaining the failure + public record SubscriptionFailureDTO( + @JsonProperty(value = "uuid", required = true) @UUID String uuid, + @JsonProperty(value = "feedUrl", required = true) String feedUrl, + @JsonProperty(value = "message", required = true) String message + ) { + } + + /// A paginated DTO representing a list of subscriptions + /// + /// @param subscriptions the [UserSubscriptionDTO] 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 + /// @param totalPages the total number of pages in the result set + /// @param numberOfElements the number of elements in the current page + /// @param totalElements the total number of elements in the result set + /// @param size the size limit applied to the page + public record SubscriptionPageDTO( + List subscriptions, + boolean first, + boolean last, + int page, + int totalPages, + long totalElements, + int numberOfElements, + int size + ) { + public static SubscriptionPageDTO fromPage(Page page) { + return new SubscriptionPageDTO( + page.getContent(), + page.isFirst(), + page.isLast(), + page.getNumber(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumberOfElements(), + page.getSize() + ); + } + } +} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/model/Subscription.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java similarity index 80% rename from src/main/java/org/openpodcastapi/opa/subscription/model/Subscription.java rename to src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java index d04a5c2..6109490 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/model/Subscription.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.subscription.model; +package org.openpodcastapi.opa.subscription; import jakarta.persistence.*; import lombok.*; @@ -11,36 +11,27 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Getter +@Setter @Table(name = "subscriptions") -public class Subscription { +public class SubscriptionEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Generated - @Getter private Long id; - @Getter - @Setter @Column(unique = true, nullable = false, updatable = false, columnDefinition = "uuid") private UUID uuid; - @Getter - @Setter @Column(nullable = false) private String feedUrl; - @Getter - @Setter @OneToMany(mappedBy = "subscription") - private Set subscribers; + private Set subscribers; - @Getter - @Setter @Column(updatable = false, nullable = false) private Instant createdAt; - @Getter - @Setter @Column(nullable = false) private Instant updatedAt; diff --git a/src/main/java/org/openpodcastapi/opa/subscription/mapper/SubscriptionMapper.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java similarity index 67% rename from src/main/java/org/openpodcastapi/opa/subscription/mapper/SubscriptionMapper.java rename to src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java index 14d606d..369c795 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/mapper/SubscriptionMapper.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java @@ -1,9 +1,7 @@ -package org.openpodcastapi.opa.subscription.mapper; +package org.openpodcastapi.opa.subscription; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.openpodcastapi.opa.subscription.dto.SubscriptionCreateDto; -import org.openpodcastapi.opa.subscription.model.Subscription; import java.util.UUID; @@ -14,7 +12,7 @@ public interface SubscriptionMapper { @Mapping(target = "subscribers", ignore = true) @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) - Subscription toEntity(SubscriptionCreateDto dto); + SubscriptionEntity toEntity(SubscriptionDTO.SubscriptionCreateDTO dto); default UUID mapStringToUUID(String feedUUID) { return UUID.fromString(feedUUID); diff --git a/src/main/java/org/openpodcastapi/opa/subscription/repository/SubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java similarity index 56% rename from src/main/java/org/openpodcastapi/opa/subscription/repository/SubscriptionRepository.java rename to src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java index bf5b96e..bdb30db 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/repository/SubscriptionRepository.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java @@ -1,6 +1,5 @@ -package org.openpodcastapi.opa.subscription.repository; +package org.openpodcastapi.opa.subscription; -import org.openpodcastapi.opa.subscription.model.Subscription; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -8,6 +7,6 @@ import java.util.UUID; @Repository -public interface SubscriptionRepository extends JpaRepository { - Optional findByUuid(UUID uuid); +public interface SubscriptionRepository extends JpaRepository { + Optional findByUuid(UUID uuid); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java similarity index 63% rename from src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java rename to src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java index aea2b35..930ca74 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java @@ -1,14 +1,9 @@ -package org.openpodcastapi.opa.subscription.controller; +package org.openpodcastapi.opa.subscription; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.service.CustomUserDetails; -import org.openpodcastapi.opa.subscription.dto.BulkSubscriptionResponse; -import org.openpodcastapi.opa.subscription.dto.SubscriptionCreateDto; -import org.openpodcastapi.opa.subscription.dto.SubscriptionPageDto; -import org.openpodcastapi.opa.subscription.dto.UserSubscriptionDto; -import org.openpodcastapi.opa.subscription.service.UserSubscriptionService; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; @@ -25,20 +20,20 @@ @Log4j2 @RequestMapping("/api/v1/subscriptions") public class SubscriptionRestController { - private final UserSubscriptionService service; + private final SubscriptionService service; /// Returns all subscriptions for a given user /// /// @param user the [CustomUserDetails] of the authenticated user /// @param pageable the [Pageable] pagination object /// @param includeUnsubscribed whether to include unsubscribed feeds in the response - /// @return a paginated list of subscriptions + /// @return a [ResponseEntity] containing [SubscriptionDTO.SubscriptionPageDTO] objects @GetMapping @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('USER')") - public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) { + public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) { log.info("{}", user.getAuthorities()); - Page dto; + Page dto; if (includeUnsubscribed) { dto = service.getAllSubscriptionsForUser(user.id(), pageable); @@ -48,57 +43,57 @@ public ResponseEntity getAllSubscriptionsForUser(@Authentic log.debug("{}", dto); - return new ResponseEntity<>(SubscriptionPageDto.fromPage(dto), HttpStatus.OK); + return new ResponseEntity<>(SubscriptionDTO.SubscriptionPageDTO.fromPage(dto), HttpStatus.OK); } /// Returns a single subscription entry by UUID /// /// @param uuid the UUID value to query for - /// @return the subscription entity + /// @return a [ResponseEntity] containing a [SubscriptionDTO.UserSubscriptionDTO] object /// @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 { + public ResponseEntity getSubscriptionByUuid(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) throws EntityNotFoundException { // Attempt to validate the UUID value from the provided string // If the value is invalid, the GlobalExceptionHandler will throw a 400. UUID uuidValue = UUID.fromString(uuid); // Fetch the subscription, throw an EntityNotFoundException if this fails - UserSubscriptionDto dto = service.getUserSubscriptionBySubscriptionUuid(uuidValue, user.id()); + SubscriptionDTO.UserSubscriptionDTO dto = service.getUserSubscriptionBySubscriptionUuid(uuidValue, user.id()); - // Return the mapped subscription entry + // Return the mapped subscriptionEntity 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 - /// @return the updated subscription entity + /// @return a [ResponseEntity] containing a [SubscriptionDTO.UserSubscriptionDTO] object /// @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) { + public ResponseEntity unsubscribeUserFromFeed(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) { // Attempt to validate the UUID value from the provided string // If the value is invalid, the GlobalExceptionHandler will throw a 400. UUID uuidValue = UUID.fromString(uuid); - UserSubscriptionDto dto = service.unsubscribeUserFromFeed(uuidValue, user.id()); + SubscriptionDTO.UserSubscriptionDTO dto = service.unsubscribeUserFromFeed(uuidValue, user.id()); return new ResponseEntity<>(dto, HttpStatus.OK); } - /// Bulk creates UserSubscriptions for a user. Creates new Subscription objects if not already present + /// Bulk creates [UserSubscriptionEntity] objects for a user. Creates new [SubscriptionEntity] objects if not already present /// - /// @param request a list of [SubscriptionCreateDto] objects - /// @return a [BulkSubscriptionResponse] object + /// @param request a list of [SubscriptionDTO.SubscriptionCreateDTO] objects + /// @return a [ResponseEntity] containing a [SubscriptionDTO.BulkSubscriptionResponseDTO] object @PostMapping @PreAuthorize("hasRole('USER')") - public ResponseEntity createUserSubscriptions(@RequestBody List request, @AuthenticationPrincipal CustomUserDetails user) { - BulkSubscriptionResponse response = service.addSubscriptions(request, user.id()); + public ResponseEntity createUserSubscriptions(@RequestBody List request, @AuthenticationPrincipal CustomUserDetails user) { + SubscriptionDTO.BulkSubscriptionResponseDTO response = service.addSubscriptions(request, user.id()); if (response.success().isEmpty() && !response.failure().isEmpty()) { // If all requests failed, return a 400 error diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java new file mode 100644 index 0000000..03dd115 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java @@ -0,0 +1,147 @@ +package org.openpodcastapi.opa.subscription; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class SubscriptionService { + private final SubscriptionRepository subscriptionRepository; + private final SubscriptionMapper subscriptionMapper; + private final UserSubscriptionRepository userSubscriptionRepository; + private final UserSubscriptionMapper userSubscriptionMapper; + private final 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] + protected SubscriptionEntity fetchOrCreateSubscription(SubscriptionDTO.SubscriptionCreateDTO dto) { + UUID feedUuid = UUID.fromString(dto.uuid()); + return subscriptionRepository + .findByUuid(feedUuid) + .orElseGet(() -> { + log.debug("Creating new subscription with UUID {}", dto.uuid()); + return subscriptionRepository.save(subscriptionMapper.toEntity(dto)); + }); + } + + /// Fetches a single subscription for an authenticated userEntity, if it exists + /// + /// @param subscriptionUuid the UUID of the subscription + /// @param userId the database ID of the user + /// @return a [SubscriptionDTO.UserSubscriptionDTO] of the user subscription + /// @throws EntityNotFoundException if no entry is found + @Transactional(readOnly = true) + public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid(UUID subscriptionUuid, Long userId) { + log.debug("Fetching subscription {} for userEntity {}", subscriptionUuid, userId); + UserSubscriptionEntity subscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionUuid) + .orElseThrow(() -> new EntityNotFoundException("subscription not found for userEntity")); + + log.debug("Subscription {} for userEntity {} found", subscriptionUuid, userId); + return userSubscriptionMapper.toDto(subscription); + } + + /// Gets all subscriptions for the authenticated userEntity + /// + /// @param userId the database ID of the authenticated userEntity + /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects + @Transactional(readOnly = true) + public Page getAllSubscriptionsForUser(Long userId, Pageable pageable) { + log.debug("Fetching subscriptions for {}", userId); + return userSubscriptionRepository.findAllByUserId(userId, pageable) + .map(userSubscriptionMapper::toDto); + } + + /// Gets all active subscriptions for the authenticated user + /// + /// @param userId the database ID of the authenticated user + /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects + @Transactional(readOnly = true) + public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) { + log.debug("Fetching all active subscriptions for {}", userId); + return userSubscriptionRepository.findAllByUserIdAndIsSubscribedTrue(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 userId the ID of the target user + /// @return a [SubscriptionDTO.UserSubscriptionDTO] representation of the subscription link + /// @throws EntityNotFoundException if no matching user is found + protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(SubscriptionEntity subscriptionEntity, Long userId) { + UserEntity userEntity = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("userEntity not found")); + log.debug("{}", userEntity); + + UserSubscriptionEntity newSubscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionEntity.getUuid()).orElseGet(() -> { + log.debug("Creating new subscription for user {} and subscription {}", userId, subscriptionEntity.getUuid()); + UserSubscriptionEntity createdSubscription = new UserSubscriptionEntity(); + createdSubscription.setIsSubscribed(true); + createdSubscription.setUser(userEntity); + createdSubscription.setSubscription(subscriptionEntity); + return userSubscriptionRepository.save(createdSubscription); + }); + + newSubscription.setIsSubscribed(true); + 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. + /// + /// @param requests a list of [SubscriptionDTO.SubscriptionCreateDTO] objects to create + /// @param userId the ID of the requesting user + /// @return a [SubscriptionDTO.BulkSubscriptionResponseDTO] DTO containing a list of successes and failures + @Transactional + public SubscriptionDTO.BulkSubscriptionResponseDTO addSubscriptions(List requests, Long userId) { + List successes = new ArrayList<>(); + List failures = new ArrayList<>(); + + log.info("{}", requests); + + for (SubscriptionDTO.SubscriptionCreateDTO dto : requests) { + try { + // Fetch or create the subscription object to subscribe the user to + SubscriptionEntity subscriptionEntity = this.fetchOrCreateSubscription(dto); + log.debug("{}", subscriptionEntity); + // If all is successful, persist the new UserSubscriptionEntity and add a UserSubscriptionDTO to the successes list + successes.add(persistUserSubscription(subscriptionEntity, userId)); + } catch (IllegalArgumentException _) { + // If the UUID of the feed is invalid, add a new failure to the failures list + failures.add(new SubscriptionDTO.SubscriptionFailureDTO(dto.uuid(), dto.feedUrl(), "invalid UUID format")); + } catch (Exception e) { + // If another failure is encountered, add it to the failures list + failures.add(new SubscriptionDTO.SubscriptionFailureDTO(dto.uuid(), dto.feedUrl(), e.getMessage())); + } + } + + // Return the entire DTO of successes and failures + return new SubscriptionDTO.BulkSubscriptionResponseDTO(successes, failures); + } + + /// Updates the status of a subscription for a given user + /// + /// @param feedUUID the UUID of the subscription feed + /// @param userId the ID of the user + /// @return a [SubscriptionDTO.UserSubscriptionDTO] containing the updated object + @Transactional + public SubscriptionDTO.UserSubscriptionDTO unsubscribeUserFromFeed(UUID feedUUID, Long userId) { + UserSubscriptionEntity subscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, feedUUID) + .orElseThrow(() -> new EntityNotFoundException("no subscription found")); + + subscription.setIsSubscribed(false); + return userSubscriptionMapper.toDto(userSubscriptionRepository.save(subscription)); + } +} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/model/UserSubscription.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java similarity index 77% rename from src/main/java/org/openpodcastapi/opa/subscription/model/UserSubscription.java rename to src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java index 1994b7c..97ee197 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/model/UserSubscription.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java @@ -1,8 +1,8 @@ -package org.openpodcastapi.opa.subscription.model; +package org.openpodcastapi.opa.subscription; import jakarta.persistence.*; import lombok.*; -import org.openpodcastapi.opa.user.model.User; +import org.openpodcastapi.opa.user.UserEntity; import java.time.Instant; import java.util.UUID; @@ -10,43 +10,33 @@ @Entity @NoArgsConstructor @AllArgsConstructor +@Getter +@Setter @Builder @Table(name = "user_subscription") -public class UserSubscription { +public class UserSubscriptionEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Generated private Long id; - @Getter - @Setter @Column(unique = true, nullable = false, updatable = false, columnDefinition = "uuid") private UUID uuid; - @Getter - @Setter @ManyToOne @JoinColumn(name = "user_id") - private User user; + private UserEntity user; - @Getter - @Setter @ManyToOne @JoinColumn(name = "subscription_id") - private Subscription subscription; + private SubscriptionEntity subscription; - @Getter - @Setter @Column(columnDefinition = "boolean default true") private Boolean isSubscribed; - @Getter - @Setter @Column(nullable = false, updatable = false) private Instant createdAt; - @Getter - @Setter @Column(nullable = false) private Instant updatedAt; diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionMapper.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionMapper.java new file mode 100644 index 0000000..281d1c3 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionMapper.java @@ -0,0 +1,11 @@ +package org.openpodcastapi.opa.subscription; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserSubscriptionMapper { + @Mapping(target = "uuid", source = "userSubscriptionEntity.subscription.uuid") + @Mapping(target = "feedUrl", source = "userSubscriptionEntity.subscription.feedUrl") + SubscriptionDTO.UserSubscriptionDTO toDto(UserSubscriptionEntity userSubscriptionEntity); +} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java new file mode 100644 index 0000000..7f9c0d5 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java @@ -0,0 +1,18 @@ +package org.openpodcastapi.opa.subscription; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserSubscriptionRepository extends JpaRepository { + Optional findByUserIdAndSubscriptionUuid(Long userId, UUID subscriptionUuid); + + Page findAllByUserId(Long userId, Pageable pageable); + + Page findAllByUserIdAndIsSubscribedTrue(Long userId, Pageable pageable); +} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/dto/BulkSubscriptionResponse.java b/src/main/java/org/openpodcastapi/opa/subscription/dto/BulkSubscriptionResponse.java deleted file mode 100644 index a55dda4..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/dto/BulkSubscriptionResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.openpodcastapi.opa.subscription.dto; - -import java.util.List; - -/// A DTO representing a bulk subscription creation -/// -/// @param success a list of creation successes -/// @param failure a list of creation failures -public record BulkSubscriptionResponse( - List success, - List failure -) { -} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionCreateDto.java b/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionCreateDto.java deleted file mode 100644 index 2055bb9..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionCreateDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.openpodcastapi.opa.subscription.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.constraints.NotNull; -import org.hibernate.validator.constraints.UUID; - -/// A DTO representing a new subscription -/// -/// @param feedUrl the URL of the feed -/// @param uuid the UUID of the feed calculated by the client -public record SubscriptionCreateDto( - @JsonProperty(required = true) @NotNull @UUID String uuid, - @JsonProperty(required = true) @NotNull String feedUrl -) { -} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionFailureDto.java b/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionFailureDto.java deleted file mode 100644 index ff5b9a7..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionFailureDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.openpodcastapi.opa.subscription.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import org.hibernate.validator.constraints.UUID; - -/// A DTO representing a failed subscription creation -/// -/// @param uuid the UUID of the failed subscription -/// @param feedUrl the feed URL of the failed subscription -/// @param message the error message explaining the failure -public record SubscriptionFailureDto( - @JsonProperty(value = "uuid", required = true) @UUID String uuid, - @JsonProperty(value = "feedUrl", required = true) String feedUrl, - @JsonProperty(value = "message", required = true) String message -) { -} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionPageDto.java b/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionPageDto.java deleted file mode 100644 index ab0207b..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionPageDto.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.openpodcastapi.opa.subscription.dto; - -import org.springframework.data.domain.Page; - -import java.util.List; - -/// A paginated DTO representing a list of subscriptions -/// -/// @param subscriptions the [UserSubscriptionDto] 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 -/// @param totalPages the total number of pages in the result set -/// @param numberOfElements the number of elements in the current page -/// @param totalElements the total number of elements in the result set -/// @param size the size limit applied to the page -public record SubscriptionPageDto( - List subscriptions, - boolean first, - boolean last, - int page, - int totalPages, - long totalElements, - int numberOfElements, - int size -) { - public static SubscriptionPageDto fromPage(Page page) { - return new SubscriptionPageDto( - page.getContent(), - page.isFirst(), - page.isLast(), - page.getNumber(), - page.getTotalPages(), - page.getTotalElements(), - page.getNumberOfElements(), - page.getSize() - ); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/dto/UserSubscriptionDto.java b/src/main/java/org/openpodcastapi/opa/subscription/dto/UserSubscriptionDto.java deleted file mode 100644 index 68be02c..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/dto/UserSubscriptionDto.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.openpodcastapi.opa.subscription.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import org.hibernate.validator.constraints.URL; -import org.hibernate.validator.constraints.UUID; - -import java.time.Instant; - -/// A DTO representing a user's subscription to a given feed -/// -/// @param uuid the feed UUID -/// @param feedUrl the feed URL -/// @param createdAt the date at which the subscription link was created -/// @param updatedAt the date at which the subscription link was last updated -/// @param isSubscribed whether the user is currently subscribed to the feed -public record UserSubscriptionDto( - @JsonProperty(required = true) @UUID java.util.UUID uuid, - @JsonProperty(required = true) @URL String feedUrl, - @JsonProperty(required = true) Instant createdAt, - @JsonProperty(required = true) Instant updatedAt, - @JsonProperty(required = true) Boolean isSubscribed -) { -} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/mapper/UserSubscriptionMapper.java b/src/main/java/org/openpodcastapi/opa/subscription/mapper/UserSubscriptionMapper.java deleted file mode 100644 index 3edaf40..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/mapper/UserSubscriptionMapper.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.openpodcastapi.opa.subscription.mapper; - -import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.openpodcastapi.opa.subscription.dto.UserSubscriptionDto; -import org.openpodcastapi.opa.subscription.model.UserSubscription; - -@Mapper(componentModel = "spring") -public interface UserSubscriptionMapper { - @Mapping(target = "uuid", source = "userSubscription.subscription.uuid") - @Mapping(target = "feedUrl", source = "userSubscription.subscription.feedUrl") - UserSubscriptionDto toDto(UserSubscription userSubscription); -} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java deleted file mode 100644 index 1f4b6d3..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.openpodcastapi.opa.subscription.repository; - -import org.openpodcastapi.opa.subscription.model.UserSubscription; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; -import java.util.UUID; - -@Repository -public interface UserSubscriptionRepository extends JpaRepository { - Optional findByUserIdAndSubscriptionUuid(Long userId, UUID subscriptionUuid); - - Page findAllByUserId(Long userId, Pageable pageable); - - Page findAllByUserIdAndIsSubscribedTrue(Long userId, Pageable pageable); -} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/service/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/service/SubscriptionService.java deleted file mode 100644 index 26ab8fd..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/service/SubscriptionService.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.openpodcastapi.opa.subscription.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.subscription.dto.SubscriptionCreateDto; -import org.openpodcastapi.opa.subscription.mapper.SubscriptionMapper; -import org.openpodcastapi.opa.subscription.model.Subscription; -import org.openpodcastapi.opa.subscription.repository.SubscriptionRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Log4j2 -public class SubscriptionService { - private final SubscriptionRepository subscriptionRepository; - private final SubscriptionMapper subscriptionMapper; - - /// Fetches an existing repository from the database or creates a new one if none is found - /// - /// @param dto the [SubscriptionCreateDto] containing the subscription data - /// @return the fetched or created [Subscription] - @Transactional - protected Subscription fetchOrCreateSubscription(SubscriptionCreateDto dto) { - UUID feedUuid = UUID.fromString(dto.uuid()); - return subscriptionRepository - .findByUuid(feedUuid) - .orElseGet(() -> { - log.debug("Creating new subscription with UUID {}", dto.uuid()); - return subscriptionRepository.save(subscriptionMapper.toEntity(dto)); - }); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/subscription/service/UserSubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/service/UserSubscriptionService.java deleted file mode 100644 index f50359a..0000000 --- a/src/main/java/org/openpodcastapi/opa/subscription/service/UserSubscriptionService.java +++ /dev/null @@ -1,140 +0,0 @@ -package org.openpodcastapi.opa.subscription.service; - -import jakarta.persistence.EntityNotFoundException; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.subscription.dto.BulkSubscriptionResponse; -import org.openpodcastapi.opa.subscription.dto.SubscriptionCreateDto; -import org.openpodcastapi.opa.subscription.dto.SubscriptionFailureDto; -import org.openpodcastapi.opa.subscription.dto.UserSubscriptionDto; -import org.openpodcastapi.opa.subscription.mapper.UserSubscriptionMapper; -import org.openpodcastapi.opa.subscription.model.Subscription; -import org.openpodcastapi.opa.subscription.model.UserSubscription; -import org.openpodcastapi.opa.subscription.repository.UserSubscriptionRepository; -import org.openpodcastapi.opa.user.model.User; -import org.openpodcastapi.opa.user.repository.UserRepository; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Log4j2 -public class UserSubscriptionService { - private final UserSubscriptionRepository userSubscriptionRepository; - private final UserSubscriptionMapper userSubscriptionMapper; - private final UserRepository userRepository; - private final SubscriptionService subscriptionService; - - /// Fetches a single subscription for an authenticated user, if it exists - /// - /// @param subscriptionUuid the UUID of the subscription - /// @param userId the database ID of the user - /// @return a [UserSubscriptionDto] of the user subscription - /// @throws EntityNotFoundException if no entry is found - @Transactional(readOnly = true) - public UserSubscriptionDto getUserSubscriptionBySubscriptionUuid(UUID subscriptionUuid, Long userId) { - log.debug("Fetching subscription {} for user {}", subscriptionUuid, userId); - UserSubscription subscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionUuid) - .orElseThrow(() -> new EntityNotFoundException("subscription not found for user")); - - log.debug("Subscription {} for user {} found", subscriptionUuid, userId); - return userSubscriptionMapper.toDto(subscription); - } - - /// Gets all subscriptions for the authenticated user - /// - /// @param userId the database ID of the authenticated user - /// @return a paginated set of [UserSubscriptionDto] objects - @Transactional(readOnly = true) - public Page getAllSubscriptionsForUser(Long userId, Pageable pageable) { - log.debug("Fetching subscriptions for {}", userId); - return userSubscriptionRepository.findAllByUserId(userId, pageable) - .map(userSubscriptionMapper::toDto); - } - - /// Gets all active subscriptions for the authenticated user - /// - /// @param userId the database ID of the authenticated user - /// @return a paginated set of [UserSubscriptionDto] objects - @Transactional(readOnly = true) - public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) { - log.debug("Fetching all active subscriptions for {}", userId); - return userSubscriptionRepository.findAllByUserIdAndIsSubscribedTrue(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 subscription the target subscription - /// @param userId the ID of the target user - /// @return a [UserSubscriptionDto] representation of the subscription link - /// @throws EntityNotFoundException if no matching user is found - protected UserSubscriptionDto persistUserSubscription(Subscription subscription, Long userId) { - User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("user not found")); - log.debug("{}", user); - - UserSubscription newSubscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscription.getUuid()).orElseGet(() -> { - log.debug("Creating new user subscription for user {} and subscription {}", userId, subscription.getUuid()); - UserSubscription createdSubscription = new UserSubscription(); - createdSubscription.setIsSubscribed(true); - createdSubscription.setUser(user); - createdSubscription.setSubscription(subscription); - return userSubscriptionRepository.save(createdSubscription); - }); - - newSubscription.setIsSubscribed(true); - return userSubscriptionMapper.toDto(userSubscriptionRepository.save(newSubscription)); - } - - /// Creates UserSubscription links in bulk. If the Subscription isn't already in the system, this is added before the user is subscribed. - /// - /// @param requests a list of [SubscriptionCreateDto] objects to create - /// @param userId the ID of the requesting user - /// @return a [BulkSubscriptionResponse] DTO containing a list of successes and failures - @Transactional - public BulkSubscriptionResponse addSubscriptions(List requests, Long userId) { - List successes = new ArrayList<>(); - List failures = new ArrayList<>(); - - log.info("{}", requests); - - for (SubscriptionCreateDto dto : requests) { - try { - // Fetch or create the subscription object to subscribe the user to - Subscription subscription = subscriptionService.fetchOrCreateSubscription(dto); - log.debug("{}", subscription); - // If all is successful, persist the new UserSubscription and add a UserSubscriptionDto to the successes list - successes.add(persistUserSubscription(subscription, userId)); - } catch (IllegalArgumentException _) { - // If the UUID of the feed is invalid, add a new failure to the failures list - failures.add(new SubscriptionFailureDto(dto.uuid(), dto.feedUrl(), "invalid UUID format")); - } catch (Exception e) { - // If another failure is encountered, add it to the failures list - failures.add(new SubscriptionFailureDto(dto.uuid(), dto.feedUrl(), e.getMessage())); - } - } - - // Return the entire DTO of successes and failures - return new BulkSubscriptionResponse(successes, failures); - } - - /// Updates the status of a subscription for a given user - /// - /// @param feedUUID the UUID of the subscription feed - /// @param userId the ID of the user - /// @return a [UserSubscriptionDto] containing the updated object - @Transactional - public UserSubscriptionDto unsubscribeUserFromFeed(UUID feedUUID, Long userId) { - UserSubscription subscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, feedUUID) - .orElseThrow(() -> new EntityNotFoundException("no subscription found")); - - subscription.setIsSubscribed(false); - return userSubscriptionMapper.toDto(userSubscriptionRepository.save(subscription)); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java new file mode 100644 index 0000000..63272f4 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java @@ -0,0 +1,74 @@ +package org.openpodcastapi.opa.user; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public class UserDTO { + /// A DTO representing a user response over the api + /// + /// @param uuid the UUID of the user + /// @param username the username of the user + /// @param email the email address of the user + /// @param createdAt the timestamp at which the user was created + /// @param updatedAt the timestamp at which the user was last updated + public record UserResponseDTO( + @JsonProperty(required = true) UUID uuid, + @JsonProperty(required = true) String username, + @JsonProperty(required = true) String email, + @JsonProperty(required = true) Instant createdAt, + @JsonProperty(required = true) Instant updatedAt + ) { + } + + /// A paginated DTO representing a list of subscriptions + /// + /// @param users the [UserResponseDTO] 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 + /// @param totalPages the total number of pages in the result set + /// @param numberOfElements the number of elements in the current page + /// @param totalElements the total number of elements in the result set + /// @param size the size limit applied to the page + public record UserPageDTO( + List users, + boolean first, + boolean last, + int page, + int totalPages, + long totalElements, + int numberOfElements, + int size + ) { + public static UserPageDTO fromPage(Page page) { + return new UserPageDTO( + page.getContent(), + page.isFirst(), + page.isLast(), + page.getNumber(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumberOfElements(), + page.getSize() + ); + } + } + + /// A DTO representing a new user + /// + /// @param email the user's email address + /// @param username the user's username + /// @param password the user's unhashed password + public record CreateUserDTO( + @JsonProperty(required = true) @NotNull String username, + @JsonProperty(required = true) @NotNull String password, + @JsonProperty(required = true) @NotNull @Email String email + ) { + } +} diff --git a/src/main/java/org/openpodcastapi/opa/user/model/User.java b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java similarity index 75% rename from src/main/java/org/openpodcastapi/opa/user/model/User.java rename to src/main/java/org/openpodcastapi/opa/user/UserEntity.java index 5dcee1e..cd470dc 100644 --- a/src/main/java/org/openpodcastapi/opa/user/model/User.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java @@ -1,8 +1,8 @@ -package org.openpodcastapi.opa.user.model; +package org.openpodcastapi.opa.user; import jakarta.persistence.*; import lombok.*; -import org.openpodcastapi.opa.subscription.model.UserSubscription; +import org.openpodcastapi.opa.subscription.UserSubscriptionEntity; import java.time.Instant; import java.util.Collections; @@ -13,56 +13,41 @@ @Entity @Table(name = "users") @Builder +@Getter +@Setter @NoArgsConstructor @AllArgsConstructor -public class User { +public class UserEntity { @Id - @Getter @Generated @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Getter - @Setter @Column(unique = true, nullable = false, updatable = false, columnDefinition = "uuid") private UUID uuid; - @Getter - @Setter @Column(nullable = false, unique = true) private String username; - @Getter - @Setter @Column(nullable = false) private String password; - @Getter - @Setter @Column(nullable = false, unique = true) private String email; - @Getter - @Setter @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE) - private Set subscriptions; + private Set subscriptions; - @Getter - @Setter @ElementCollection(fetch = FetchType.EAGER) @Builder.Default @Enumerated(EnumType.STRING) - @CollectionTable(name="user_roles", joinColumns = @JoinColumn(name = "user_id")) + @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) private Set userRoles = new HashSet<>(Collections.singletonList(UserRoles.USER)); - @Getter - @Setter @Column(updatable = false) private Instant createdAt; - @Getter - @Setter private Instant updatedAt; @PrePersist diff --git a/src/main/java/org/openpodcastapi/opa/user/mapper/UserMapper.java b/src/main/java/org/openpodcastapi/opa/user/UserMapper.java similarity index 64% rename from src/main/java/org/openpodcastapi/opa/user/mapper/UserMapper.java rename to src/main/java/org/openpodcastapi/opa/user/UserMapper.java index f3d71ec..8a3b2db 100644 --- a/src/main/java/org/openpodcastapi/opa/user/mapper/UserMapper.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserMapper.java @@ -1,14 +1,11 @@ -package org.openpodcastapi.opa.user.mapper; +package org.openpodcastapi.opa.user; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.openpodcastapi.opa.user.dto.CreateUserDto; -import org.openpodcastapi.opa.user.dto.UserDto; -import org.openpodcastapi.opa.user.model.User; @Mapper(componentModel = "spring") public interface UserMapper { - UserDto toDto(User user); + UserDTO.UserResponseDTO toDto(UserEntity userEntity); @Mapping(target = "uuid", ignore = true) @Mapping(target = "id", ignore = true) @@ -17,5 +14,5 @@ public interface UserMapper { @Mapping(target = "userRoles", ignore = true) @Mapping(target = "updatedAt", ignore = true) @Mapping(target = "createdAt", ignore = true) - User toEntity(CreateUserDto dto); + 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 new file mode 100644 index 0000000..81eaff9 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java @@ -0,0 +1,20 @@ +package org.openpodcastapi.opa.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + Optional getUserByUuid(UUID uuid); + + Optional getUserByUsername(String username); + + Boolean existsUserByUsername(String username); + + Boolean existsUserByEmail(String email); + + Optional findByUsername(String username); +} diff --git a/src/main/java/org/openpodcastapi/opa/user/controller/UserRestController.java b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java similarity index 58% rename from src/main/java/org/openpodcastapi/opa/user/controller/UserRestController.java rename to src/main/java/org/openpodcastapi/opa/user/UserRestController.java index a80c61e..4325b07 100644 --- a/src/main/java/org/openpodcastapi/opa/user/controller/UserRestController.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java @@ -1,10 +1,6 @@ -package org.openpodcastapi.opa.user.controller; +package org.openpodcastapi.opa.user; import lombok.RequiredArgsConstructor; -import org.openpodcastapi.opa.user.dto.CreateUserDto; -import org.openpodcastapi.opa.user.dto.UserDto; -import org.openpodcastapi.opa.user.dto.UserPageDto; -import org.openpodcastapi.opa.user.service.UserService; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; @@ -21,25 +17,37 @@ public class UserRestController { private final UserService service; + /// Returns all users + /// + /// @param pageable the [Pageable] options used for pagination + /// @return a [ResponseEntity] containing [UserDTO.UserPageDTO] objects @GetMapping @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('ADMIN')") - public ResponseEntity getAllUsers(Pageable pageable) { - Page users = service.getAllUsers(pageable); + public ResponseEntity getAllUsers(Pageable pageable) { + Page users = service.getAllUsers(pageable); - return new ResponseEntity<>(UserPageDto.fromPage(users), HttpStatus.OK); + return new ResponseEntity<>(UserDTO.UserPageDTO.fromPage(users), HttpStatus.OK); } + /// Creates a new user in the system + /// + /// @param request a [UserDTO.CreateUserDTO] request body + /// @return a [ResponseEntity] containing [UserDTO.UserResponseDTO] objects @PostMapping @ResponseStatus(HttpStatus.CREATED) - public ResponseEntity createUser(@RequestBody @Validated CreateUserDto request) { + public ResponseEntity createUser(@RequestBody @Validated UserDTO.CreateUserDTO request) { // Create and persist the user - UserDto dto = service.createAndPersistUser(request); + UserDTO.UserResponseDTO dto = service.createAndPersistUser(request); // Return the user DTO with a `201` status. return new ResponseEntity<>(dto, HttpStatus.CREATED); } + /// Fetch a specific user by UUID + /// + /// @param uuid the [UUID] of the user + /// @return a [ResponseEntity] containing a summary of the action @DeleteMapping("/{uuid}") @PreAuthorize("hasRole('ADMIN') or #uuid == principal.uuid") public ResponseEntity deleteUser(@PathVariable String uuid) { diff --git a/src/main/java/org/openpodcastapi/opa/user/model/UserRoles.java b/src/main/java/org/openpodcastapi/opa/user/UserRoles.java similarity index 81% rename from src/main/java/org/openpodcastapi/opa/user/model/UserRoles.java rename to src/main/java/org/openpodcastapi/opa/user/UserRoles.java index a4f0816..b4efef5 100644 --- a/src/main/java/org/openpodcastapi/opa/user/model/UserRoles.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRoles.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.user.model; +package org.openpodcastapi.opa.user; /// The roles associated with users. All users have `USER` permissions. /// Admins require the `ADMIN` role to perform administrative functions. diff --git a/src/main/java/org/openpodcastapi/opa/user/UserService.java b/src/main/java/org/openpodcastapi/opa/user/UserService.java new file mode 100644 index 0000000..c7f1059 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/user/UserService.java @@ -0,0 +1,69 @@ +package org.openpodcastapi.opa.user; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class UserService { + private static final String USER_NOT_FOUND = "User not found"; + private final UserRepository repository; + private final UserMapper mapper; + private final BCryptPasswordEncoder passwordEncoder; + + /// Persists a user to the database + /// + /// @param dto the [UserDTO.CreateUserDTO] for the user + /// @return the formatted [UserDTO.UserResponseDTO] 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 { + // If the user already exists in the system, throw an exception and return a `400` response. + if (repository.existsUserByEmail(dto.email()) || repository.existsUserByUsername(dto.username())) { + throw new DataIntegrityViolationException("User already exists"); + } + + // Create a new user with a hashed password and a default `USER` role. + UserEntity newUserEntity = mapper.toEntity(dto); + newUserEntity.setPassword(passwordEncoder.encode(dto.password())); + newUserEntity.getUserRoles().add(UserRoles.USER); + + // Save the user and return the DTO representation. + UserEntity persistedUserEntity = repository.save(newUserEntity); + log.debug("persisted user {}", persistedUserEntity.getUuid()); + return mapper.toDto(persistedUserEntity); + } + + @Transactional(readOnly = true) + public Page getAllUsers(Pageable pageable) { + Page users = repository.findAll(pageable); + + log.debug("returning {} users", users.getTotalElements()); + + return users.map(mapper::toDto); + } + + /// Deletes a user from the database + /// + /// @param uuid the [UUID] of the user to delete + /// @return a success message + /// @throws EntityNotFoundException if no matching record is found + @Transactional + public String deleteUser(UUID uuid) throws EntityNotFoundException { + UserEntity userEntity = repository.getUserByUuid(uuid).orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + + repository.delete(userEntity); + + return "user " + uuid.toString() + "deleted"; + } +} diff --git a/src/main/java/org/openpodcastapi/opa/user/dto/CreateUserDto.java b/src/main/java/org/openpodcastapi/opa/user/dto/CreateUserDto.java deleted file mode 100644 index 31c4b3a..0000000 --- a/src/main/java/org/openpodcastapi/opa/user/dto/CreateUserDto.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.openpodcastapi.opa.user.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotNull; - -/// A DTO representing a new user -/// -/// @param email the user's email address -/// @param username the user's username -/// @param password the user's unhashed password -public record CreateUserDto( - @JsonProperty(required = true) @NotNull String username, - @JsonProperty(required = true) @NotNull String password, - @JsonProperty(required = true) @NotNull @Email String email -) { -} diff --git a/src/main/java/org/openpodcastapi/opa/user/dto/UserDto.java b/src/main/java/org/openpodcastapi/opa/user/dto/UserDto.java deleted file mode 100644 index d6f662f..0000000 --- a/src/main/java/org/openpodcastapi/opa/user/dto/UserDto.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.openpodcastapi.opa.user.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.time.Instant; -import java.util.UUID; - -/// A DTO representing a user response over the API -/// -/// @param uuid the UUID of the user -/// @param username the username of the user -/// @param email the email address of the user -/// @param createdAt the timestamp at which the user was created -/// @param updatedAt the timestamp at which the user was last updated -public record UserDto( - @JsonProperty(required = true) UUID uuid, - @JsonProperty(required = true) String username, - @JsonProperty(required = true) String email, - @JsonProperty(required = true) Instant createdAt, - @JsonProperty(required = true) Instant updatedAt -) { -} diff --git a/src/main/java/org/openpodcastapi/opa/user/dto/UserPageDto.java b/src/main/java/org/openpodcastapi/opa/user/dto/UserPageDto.java deleted file mode 100644 index 12fa6fa..0000000 --- a/src/main/java/org/openpodcastapi/opa/user/dto/UserPageDto.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.openpodcastapi.opa.user.dto; - -import org.springframework.data.domain.Page; - -import java.util.List; - -/// A paginated DTO representing a list of subscriptions -/// -/// @param users the [UserDto] 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 -/// @param totalPages the total number of pages in the result set -/// @param numberOfElements the number of elements in the current page -/// @param totalElements the total number of elements in the result set -/// @param size the size limit applied to the page -public record UserPageDto( - List users, - boolean first, - boolean last, - int page, - int totalPages, - long totalElements, - int numberOfElements, - int size -) { - public static UserPageDto fromPage(Page page) { - return new UserPageDto( - page.getContent(), - page.isFirst(), - page.isLast(), - page.getNumber(), - page.getTotalPages(), - page.getTotalElements(), - page.getNumberOfElements(), - page.getSize() - ); - } -} diff --git a/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java b/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java deleted file mode 100644 index 43019fb..0000000 --- a/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.openpodcastapi.opa.user.repository; - -import org.openpodcastapi.opa.user.model.User; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; -import java.util.UUID; - -@Repository -public interface UserRepository extends JpaRepository { - Optional getUserByUuid(UUID uuid); - - Optional getUserByUsername(String username); - - Boolean existsUserByUsername(String username); - - Boolean existsUserByEmail(String email); - - Optional findByUsername(String username); -} diff --git a/src/main/java/org/openpodcastapi/opa/user/service/UserService.java b/src/main/java/org/openpodcastapi/opa/user/service/UserService.java deleted file mode 100644 index f245d85..0000000 --- a/src/main/java/org/openpodcastapi/opa/user/service/UserService.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.openpodcastapi.opa.user.service; - -import jakarta.persistence.EntityNotFoundException; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.user.dto.CreateUserDto; -import org.openpodcastapi.opa.user.dto.UserDto; -import org.openpodcastapi.opa.user.mapper.UserMapper; -import org.openpodcastapi.opa.user.model.User; -import org.openpodcastapi.opa.user.model.UserRoles; -import org.openpodcastapi.opa.user.repository.UserRepository; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Log4j2 -public class UserService { - private static final String USER_NOT_FOUND = "User not found"; - private final UserRepository repository; - private final UserMapper mapper; - private final BCryptPasswordEncoder passwordEncoder; - - /// Persists a user to the database - /// - /// @param dto the [CreateUserDto] 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 createAndPersistUser(CreateUserDto dto) throws DataIntegrityViolationException { - // If the user already exists in the system, throw an exception and return a `400` response. - if (repository.existsUserByEmail(dto.email()) || repository.existsUserByUsername(dto.username())) { - throw new DataIntegrityViolationException("User already exists"); - } - - // Create a new user with a hashed password and a default `USER` role. - User newUser = mapper.toEntity(dto); - newUser.setPassword(passwordEncoder.encode(dto.password())); - newUser.getUserRoles().add(UserRoles.USER); - - // Save the user and return the DTO representation. - User persistedUser = repository.save(newUser); - log.debug("persisted user {}", persistedUser.getUuid()); - return mapper.toDto(persistedUser); - } - - /// Fetches a user record by UUID and returns a mapped DTO. - /// - /// @param uuid the UUID of the user to fetch - /// @return the formatted DTO representation of the user - /// @throws EntityNotFoundException if no matching record is found - @Transactional(readOnly = true) - public UserDto getUser(UUID uuid) throws EntityNotFoundException { - // Attempt to fetch the user from the database. - User user = repository.getUserByUuid(uuid) - .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); - - log.debug("user {} found", user.getUuid()); - return mapper.toDto(user); - } - - @Transactional(readOnly = true) - public Page getAllUsers(Pageable pageable) { - Page users = repository.findAll(pageable); - - log.debug("returning {} users", users.getTotalElements()); - - return users.map(mapper::toDto); - } - - /// Promotes a user to admin. - /// - /// @param uuid the UUID of the user to be promoted - /// @throws EntityNotFoundException if no matching record is found - @Transactional - public void promoteUserToAdmin(UUID uuid) { - // Attempt to fetch the user from the database. - // If the user doesn't exist, throw a not found exception and return a `404` response. - User user = repository.getUserByUuid(uuid) - .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); - - // Add the `ADMIN` role to the user and persist it in the database. - user.getUserRoles().add(UserRoles.ADMIN); - log.debug("admin role added to user {}", user.getUuid()); - repository.save(user); - } - - /// Demotes a user by removing the ADMIN role. - /// - /// @param uuid the UUID of the user to demote. - /// @throws EntityNotFoundException if no matching record is found - @Transactional - public void demoteUser(UUID uuid) { - // Attempt to fetch the user from the database. - // If the user doesn't exist, throw a not found exception and return a `404` response. - User user = repository.getUserByUuid(uuid) - .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); - - // Remove the `ADMIN` role from the user and persist it in the database. - user.getUserRoles().remove(UserRoles.ADMIN); - log.debug("admin role removed from user {}", user.getUuid()); - repository.save(user); - } - - /// Deletes a user from the database - /// - /// @param uuid the UUID of the user to delete - /// @return a success message - /// @throws EntityNotFoundException if no matching record is found - @Transactional - public String deleteUser(UUID uuid) throws EntityNotFoundException { - User user = repository.getUserByUuid(uuid).orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); - - repository.delete(user); - - return "user " + uuid.toString() + "deleted"; - } -} diff --git a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java index acc58d0..698443b 100644 --- a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java +++ b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java @@ -2,9 +2,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.user.model.User; -import org.openpodcastapi.opa.user.model.UserRoles; -import org.openpodcastapi.opa.user.repository.UserRepository; +import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserRepository; +import org.openpodcastapi.opa.user.UserRoles; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -33,7 +33,7 @@ public class AdminUserInitializer implements ApplicationRunner { @Override public void run(ApplicationArguments args) { if (userRepository.getUserByUsername(username).isEmpty()) { - User admin = new User(); + UserEntity admin = new UserEntity(); admin.setUsername(username); admin.setEmail(email); admin.setPassword(encoder.encode(password)); diff --git a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java index c7fdcff..454ff32 100644 --- a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java +++ b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java @@ -4,9 +4,9 @@ import org.junit.jupiter.api.Test; import org.openpodcastapi.opa.security.RefreshTokenRepository; import org.openpodcastapi.opa.security.TokenService; -import org.openpodcastapi.opa.user.model.User; -import org.openpodcastapi.opa.user.model.UserRoles; -import org.openpodcastapi.opa.user.repository.UserRepository; +import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserRoles; +import org.openpodcastapi.opa.user.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -59,8 +59,8 @@ class AuthApiTest { @BeforeEach void setup() { - // Mock the user lookup - User mockUser = User.builder() + // Mock the userEntity lookup + UserEntity mockUserEntity = UserEntity.builder() .id(2L) .uuid(UUID.randomUUID()) .email("test@test.test") @@ -72,17 +72,17 @@ void setup() { .build(); // Mock repository behavior for finding user by username - when(userRepository.findByUsername("test_user")).thenReturn(Optional.of(mockUser)); + when(userRepository.findByUsername("test_user")).thenReturn(Optional.of(mockUserEntity)); // Mock the refresh token validation to return the mock user - when(tokenService.validateRefreshToken(anyString(), any(User.class))) - .thenReturn(mockUser); + when(tokenService.validateRefreshToken(anyString(), any(UserEntity.class))) + .thenReturn(mockUserEntity); // Mock the access token generation - when(tokenService.generateAccessToken(any(User.class))).thenReturn("eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiI2MmJjZjczZC0xNGVjLTRkZmMtOGY5ZS1hMDQ0YjE4YjJiYTUiLCJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzYzODQzMzEwLCJleHAiOjE3NjM4NDQyMTB9.B9aj5DoVpNe6HTxXm8iTHj5XaqFCcR1ZHRZq6xiqY28YvGGStVkPpedDVZfc02-B"); + when(tokenService.generateAccessToken(any(UserEntity.class))).thenReturn("eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiI2MmJjZjczZC0xNGVjLTRkZmMtOGY5ZS1hMDQ0YjE4YjJiYTUiLCJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzYzODQzMzEwLCJleHAiOjE3NjM4NDQyMTB9.B9aj5DoVpNe6HTxXm8iTHj5XaqFCcR1ZHRZq6xiqY28YvGGStVkPpedDVZfc02-B"); // Mock the refresh token generation - when(tokenService.generateRefreshToken(any(User.class))).thenReturn("8be54fc2-70ec-48ef-a8ff-4548fd8932b8e947a7ab-99b5-4cfb-b546-ac37eafa6c98"); + when(tokenService.generateRefreshToken(any(UserEntity.class))).thenReturn("8be54fc2-70ec-48ef-a8ff-4548fd8932b8e947a7ab-99b5-4cfb-b546-ac37eafa6c98"); } @Test diff --git a/src/test/java/org/openpodcastapi/opa/helpers/UUIDHelperTest.java b/src/test/java/org/openpodcastapi/opa/helpers/UUIDHelperTest.java deleted file mode 100644 index aab7a51..0000000 --- a/src/test/java/org/openpodcastapi/opa/helpers/UUIDHelperTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.openpodcastapi.opa.helpers; - -import org.junit.jupiter.api.Test; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.openpodcastapi.opa.helpers.UUIDHelper.*; - -class UUIDHelperTest { - @Test - void sanitizeFeedUrl_shouldSanitizeValidUrl() { - final String feedUrl = "https://test.com/feed1/"; - final String expectedUrl = "test.com/feed1"; - String cleanedUrl = sanitizeFeedUrl(feedUrl); - - assertEquals(expectedUrl, cleanedUrl); - } - - @Test - void sanitizeFeedUrl_shouldSanitizeUrlWithoutScheme() { - final String feedUrl = "test.com/feed1"; - final String expectedUrl = "test.com/feed1"; - String cleanedUrl = sanitizeFeedUrl(feedUrl); - - assertEquals(expectedUrl, cleanedUrl); - } - - @Test - void sanitizeFeedUrl_shouldThrowOnInvalidUrl() { - final String feedUrl = "ftp://test.com/feed1"; - final String expectedMessage = "Invalid feed URL passed to function"; - - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> sanitizeFeedUrl(feedUrl)); - - assertTrue(exception.getMessage().contains(expectedMessage)); - } - - @Test - void getFeedUUID_shouldReturnGeneratedUUID() { - final String feedUrl = "podnews.net/rss"; - final UUID expectedUUID = UUID.fromString("9b024349-ccf0-5f69-a609-6b82873eab3c"); - - UUID calculatedUUID = getFeedUUID(feedUrl); - - assertEquals(expectedUUID, calculatedUUID); - } - - @Test - void getFeedUUID_shouldReturnDeterministicUUID() { - final String feedUrl = "podnews.net/rss"; - final UUID incorrectUUID = UUID.fromString("d5d5520d-81da-474e-928b-5fa66233a1ac"); - - UUID calculatedUUID = getFeedUUID(feedUrl); - - assertNotEquals(incorrectUUID, calculatedUUID); - } - - @Test - void validateSubscriptionUUID_shouldReturnTrueWhenValid() { - final String feedUrl = "podnews.net/rss"; - final UUID expectedUUID = UUID.fromString("9b024349-ccf0-5f69-a609-6b82873eab3c"); - - assertTrue(validateSubscriptionUUID(feedUrl, expectedUUID)); - } - - @Test - void validateSubscriptionUUID_shouldReturnFalseWhenInvalid() { - final String feedUrl = "podnews.net/rss"; - final UUID incorrectUUID = UUID.fromString("d5d5520d-81da-474e-928b-5fa66233a1ac"); - - assertFalse(validateSubscriptionUUID(feedUrl, incorrectUUID)); - } - - @Test - void validateUUIDString_shouldReturnTrueWhenValid() { - final String validUUID = "d5d5520d-81da-474e-928b-5fa66233a1ac"; - - assertTrue(validateUUIDString(validUUID)); - } - - @Test - void validateUUIDString_shouldReturnFalseWhenInvalid() { - final String validUUID = "not-a-uuid"; - - assertFalse(validateUUIDString(validUUID)); - } -} diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java similarity index 55% rename from src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java rename to src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java index 6c0c8fe..b135c72 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java @@ -1,14 +1,15 @@ package org.openpodcastapi.opa.subscriptions; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.openpodcastapi.opa.service.CustomUserDetails; -import org.openpodcastapi.opa.subscription.dto.BulkSubscriptionResponse; -import org.openpodcastapi.opa.subscription.dto.SubscriptionCreateDto; -import org.openpodcastapi.opa.subscription.dto.SubscriptionFailureDto; -import org.openpodcastapi.opa.subscription.dto.UserSubscriptionDto; -import org.openpodcastapi.opa.subscription.service.UserSubscriptionService; -import org.openpodcastapi.opa.user.model.UserRoles; +import org.openpodcastapi.opa.security.TokenService; +import org.openpodcastapi.opa.subscription.SubscriptionDTO; +import org.openpodcastapi.opa.subscription.SubscriptionService; +import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserRepository; +import org.openpodcastapi.opa.user.UserRoles; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -17,9 +18,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -27,19 +26,20 @@ 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; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.restdocs.request.RequestDocumentation.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -47,30 +47,64 @@ @ActiveProfiles("test") @AutoConfigureMockMvc @AutoConfigureRestDocs(outputDir = "target/generated-snippets") -class SubscriptionRestControllerTest { +class SubscriptionEntityRestControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; + @Autowired + private TokenService tokenService; + + @MockitoBean + private UserRepository userRepository; + @MockitoBean - private UserSubscriptionService subscriptionService; + private SubscriptionService subscriptionService; + + private String accessToken; + + 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.getUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); + + accessToken = tokenService.generateAccessToken(mockUser); + } @Test - @WithMockUser(username = "alice") - void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { - CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "alice", "alice@test.com", Set.of(UserRoles.USER)); + void getAllSubscriptionsForAnonymous_shouldReturn401() throws Exception { + mockMvc.perform(get("/api/v1/subscriptions") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isUnauthorized()); + } - UserSubscriptionDto sub1 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true); - UserSubscriptionDto sub2 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), true); - Page page = new PageImpl<>(List.of(sub1, sub2)); + @Test + @WithMockUser(username = "user") + void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { + SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true); + SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), true); + Page page = new PageImpl<>(List.of(sub1, sub2)); - when(subscriptionService.getAllActiveSubscriptionsForUser(eq(user.id()), any(Pageable.class))) + when(subscriptionService.getAllActiveSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class))) .thenReturn(page); mockMvc.perform(get("/api/v1/subscriptions") - .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities()))) + .header("Authorization", "Bearer " + accessToken) .param("page", "0") .param("size", "20")) .andExpect(status().isOk()) @@ -78,6 +112,9 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { .andDo(document("subscriptions-list", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("The access token used to authenticate the user") + ), queryParameters( parameterWithName("page").description("The page number to fetch").optional(), parameterWithName("size").description("The number of results to include on each page").optional(), @@ -86,10 +123,10 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { .description("If true, includes unsubscribed feeds in the results. Defaults to false.") ), responseFields( - fieldWithPath("subscriptions[].uuid").description("The UUID of the subscription").type(JsonFieldType.STRING), - fieldWithPath("subscriptions[].feedUrl").description("The feed URL of the subscription").type(JsonFieldType.STRING), - fieldWithPath("subscriptions[].createdAt").description("Creation timestamp of the subscription").type(JsonFieldType.STRING), - fieldWithPath("subscriptions[].updatedAt").description("Last update timestamp of the subscription").type(JsonFieldType.STRING), + fieldWithPath("subscriptions[].uuid").description("The UUID of the subscriptionEntity").type(JsonFieldType.STRING), + fieldWithPath("subscriptions[].feedUrl").description("The feed URL of the subscriptionEntity").type(JsonFieldType.STRING), + fieldWithPath("subscriptions[].createdAt").description("Creation timestamp of the subscriptionEntity").type(JsonFieldType.STRING), + fieldWithPath("subscriptions[].updatedAt").description("Last update timestamp of the subscriptionEntity").type(JsonFieldType.STRING), fieldWithPath("subscriptions[].isSubscribed").description("Whether the user is subscribed to the feed").type(JsonFieldType.BOOLEAN), fieldWithPath("page").description("Current page number").type(JsonFieldType.NUMBER), fieldWithPath("size").description("Size of the page").type(JsonFieldType.NUMBER), @@ -103,22 +140,17 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { } @Test - @WithMockUser(username = "alice") + @WithMockUser(username = "user") void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws Exception { - CustomUserDetails user = new CustomUserDetails( - 1L, UUID.randomUUID(), "alice", "alice@test.com", - Set.of(UserRoles.USER) - ); - - UserSubscriptionDto sub1 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true); - UserSubscriptionDto sub2 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), false); - Page page = new PageImpl<>(List.of(sub1, sub2)); + SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true); + SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), false); + Page page = new PageImpl<>(List.of(sub1, sub2)); - when(subscriptionService.getAllSubscriptionsForUser(eq(user.id()), any(Pageable.class))) + when(subscriptionService.getAllSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class))) .thenReturn(page); mockMvc.perform(get("/api/v1/subscriptions") - .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities()))) + .header("Authorization", "Bearer " + accessToken) .param("includeUnsubscribed", "true")) .andExpect(status().isOk()) .andDo(document("subscriptions-list-with-unsubscribed", @@ -126,29 +158,49 @@ void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws preprocessResponse(prettyPrint()))); } + @Test + void getSubscriptionByUuidForAnonymous_shouldReturnUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/subscriptions/{uuid}", UUID.randomUUID()) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isUnauthorized()); + } @Test - @WithMockUser(username = "alice") + @WithMockUser(username = "test") + void getNonexistentSubscription_shouldReturnNotFound() throws Exception { + when(subscriptionService.getUserSubscriptionBySubscriptionUuid(any(UUID.class), anyLong())) + .thenThrow(new EntityNotFoundException()); + + mockMvc.perform(get("/api/v1/subscriptions/{uuid}", UUID.randomUUID()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(username = "user") void getSubscriptionByUuid_shouldReturnSubscription() throws Exception { - CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "alice", "alice@test.com", Set.of(UserRoles.USER)); UUID subscriptionUuid = UUID.randomUUID(); - UserSubscriptionDto sub = new UserSubscriptionDto(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), true); - when(subscriptionService.getUserSubscriptionBySubscriptionUuid(subscriptionUuid, user.id())) + SubscriptionDTO.UserSubscriptionDTO sub = new SubscriptionDTO.UserSubscriptionDTO(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), true); + when(subscriptionService.getUserSubscriptionBySubscriptionUuid(subscriptionUuid, mockUser.getId())) .thenReturn(sub); mockMvc.perform(get("/api/v1/subscriptions/{uuid}", subscriptionUuid) - .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities())))) + .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) - .andDo(document("subscription-get", + .andDo(document("subscriptionEntity-get", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("The access token used to authenticate the user") + ), pathParameters( - parameterWithName("uuid").description("UUID of the subscription to retrieve") + parameterWithName("uuid").description("UUID of the subscriptionEntity to retrieve") ), responseFields( - fieldWithPath("uuid").description("The UUID of the subscription").type(JsonFieldType.STRING), - fieldWithPath("feedUrl").description("The feed URL of the subscription").type(JsonFieldType.STRING), + fieldWithPath("uuid").description("The UUID of the subscriptionEntity").type(JsonFieldType.STRING), + fieldWithPath("feedUrl").description("The feed URL of the subscriptionEntity").type(JsonFieldType.STRING), fieldWithPath("createdAt").description("Creation timestamp").type(JsonFieldType.STRING), fieldWithPath("updatedAt").description("Last update timestamp").type(JsonFieldType.STRING), fieldWithPath("isSubscribed").description("Whether the user is subscribed to the feed").type(JsonFieldType.BOOLEAN) @@ -157,44 +209,61 @@ void getSubscriptionByUuid_shouldReturnSubscription() throws Exception { } @Test - @WithMockUser(username = "testuser") + void createUserSubscriptionWithAnonymousUser_shouldReturnUnauthorized() throws Exception { + mockMvc.perform(post("/api/v1/subscriptions") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "user") + void createUserSubscriptionsWithoutBody_shouldReturnBadRequest() throws Exception { + mockMvc.perform(post("/api/v1/subscriptions") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(username = "user") void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { - final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", Set.of(UserRoles.USER)); final Instant timestamp = Instant.now(); final UUID goodFeedUUID = UUID.randomUUID(); final String BAD_UUID = "62ad30ce-aac0-4f0a-a811"; - SubscriptionCreateDto dto1 = new SubscriptionCreateDto(goodFeedUUID.toString(), "test.com/feed1"); - SubscriptionCreateDto dto2 = new SubscriptionCreateDto(BAD_UUID, "test.com/feed2"); + SubscriptionDTO.SubscriptionCreateDTO dto1 = new SubscriptionDTO.SubscriptionCreateDTO(goodFeedUUID.toString(), "test.com/feed1"); + SubscriptionDTO.SubscriptionCreateDTO dto2 = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2"); - BulkSubscriptionResponse response = new BulkSubscriptionResponse( - List.of(new UserSubscriptionDto(goodFeedUUID, "test.com/feed1", timestamp, timestamp, true)), - List.of(new SubscriptionFailureDto(BAD_UUID, "test.com/feed2", "invalid UUID format")) + SubscriptionDTO.BulkSubscriptionResponseDTO response = new SubscriptionDTO.BulkSubscriptionResponseDTO( + List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, true)), + List.of(new SubscriptionDTO.SubscriptionFailureDTO(BAD_UUID, "test.com/feed2", "invalid UUID format")) ); - when(subscriptionService.addSubscriptions(anyList(), eq(user.id()))) + when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId()))) .thenReturn(response); mockMvc.perform(post("/api/v1/subscriptions") - .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities()))) - .with(csrf()) + .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(List.of(dto1, dto2)))) .andExpect(status().isMultiStatus()) .andDo(document("subscriptions-bulk-create-mixed", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("The access token used to authenticate the user") + ), requestFields( - fieldWithPath("[].uuid").description("The UUID of the subscription"), - fieldWithPath("[].feedUrl").description("The feed URL of the subscription to create") + fieldWithPath("[].uuid").description("The UUID of the subscriptionEntity"), + fieldWithPath("[].feedUrl").description("The feed URL of the subscriptionEntity to create") ), responseFields( fieldWithPath("success[]").description("List of feed URLs successfully added").type(JsonFieldType.ARRAY), fieldWithPath("success[].uuid").description("The UUID of the feed").type(JsonFieldType.STRING), fieldWithPath("success[].feedUrl").description("The feed URL").type(JsonFieldType.STRING), - fieldWithPath("success[].createdAt").description("The timestamp at which the subscription was created").type(JsonFieldType.STRING), - fieldWithPath("success[].updatedAt").description("The timestamp at which the subscription was updated").type(JsonFieldType.STRING), + fieldWithPath("success[].createdAt").description("The timestamp at which the subscriptionEntity was created").type(JsonFieldType.STRING), + fieldWithPath("success[].updatedAt").description("The timestamp at which the subscriptionEntity was updated").type(JsonFieldType.STRING), fieldWithPath("success[].isSubscribed").description("Whether the user is subscribed to the feed").type(JsonFieldType.BOOLEAN), fieldWithPath("failure[]").description("List of feed URLs that failed to add").type(JsonFieldType.ARRAY), fieldWithPath("failure[].uuid").description("The UUID of the feed").type(JsonFieldType.STRING), @@ -205,72 +274,72 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { } @Test - @WithMockUser(username = "testuser") + @WithMockUser(username = "user") void createUserSubscription_shouldReturnSuccess() throws Exception { - final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", Set.of(UserRoles.USER)); - final UUID goodFeedUUID = UUID.randomUUID(); final Instant timestamp = Instant.now(); - SubscriptionCreateDto dto = new SubscriptionCreateDto(goodFeedUUID.toString(), "test.com/feed1"); + SubscriptionDTO.SubscriptionCreateDTO dto = new SubscriptionDTO.SubscriptionCreateDTO(goodFeedUUID.toString(), "test.com/feed1"); - BulkSubscriptionResponse response = new BulkSubscriptionResponse( - List.of(new UserSubscriptionDto(goodFeedUUID, "test.com/feed1", timestamp, timestamp, true)), + SubscriptionDTO.BulkSubscriptionResponseDTO response = new SubscriptionDTO.BulkSubscriptionResponseDTO( + List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, true)), List.of() ); - when(subscriptionService.addSubscriptions(anyList(), eq(user.id()))) + when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId()))) .thenReturn(response); mockMvc.perform(post("/api/v1/subscriptions") - .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities()))) - .with(csrf()) + .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(List.of(dto)))) .andExpect(status().is2xxSuccessful()) .andDo(document("subscriptions-bulk-create-success", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("The access token used to authenticate the user") + ), requestFields( - fieldWithPath("[].uuid").description("The UUID of the subscription"), - fieldWithPath("[].feedUrl").description("The feed URL of the subscription to create") + fieldWithPath("[].uuid").description("The UUID of the subscriptionEntity"), + fieldWithPath("[].feedUrl").description("The feed URL of the subscriptionEntity to create") ), responseFields( fieldWithPath("success[]").description("List of feed URLs successfully added").type(JsonFieldType.ARRAY), fieldWithPath("success[].uuid").description("The UUID of the feed").type(JsonFieldType.STRING), fieldWithPath("success[].feedUrl").description("The feed URL").type(JsonFieldType.STRING), - fieldWithPath("success[].createdAt").description("The timestamp at which the subscription was created").type(JsonFieldType.STRING), - fieldWithPath("success[].updatedAt").description("The timestamp at which the subscription was updated").type(JsonFieldType.STRING), + fieldWithPath("success[].createdAt").description("The timestamp at which the subscriptionEntity was created").type(JsonFieldType.STRING), + fieldWithPath("success[].updatedAt").description("The timestamp at which the subscriptionEntity was updated").type(JsonFieldType.STRING), fieldWithPath("success[].isSubscribed").description("Whether the user is subscribed to the feed").type(JsonFieldType.BOOLEAN), fieldWithPath("failure[]").description("List of feed URLs that failed to add").type(JsonFieldType.ARRAY).ignored()))); } @Test - @WithMockUser(username = "testuser") + @WithMockUser(username = "user") void createUserSubscription_shouldReturnFailure() throws Exception { - final CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "testuser", "test@test.com", Set.of(UserRoles.USER)); - final String BAD_UUID = "62ad30ce-aac0-4f0a-a811"; - SubscriptionCreateDto dto = new SubscriptionCreateDto(BAD_UUID, "test.com/feed2"); + SubscriptionDTO.SubscriptionCreateDTO dto = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2"); - BulkSubscriptionResponse response = new BulkSubscriptionResponse( + SubscriptionDTO.BulkSubscriptionResponseDTO response = new SubscriptionDTO.BulkSubscriptionResponseDTO( List.of(), - List.of(new SubscriptionFailureDto(BAD_UUID, "test.com/feed2", "invalid UUID format")) + List.of(new SubscriptionDTO.SubscriptionFailureDTO(BAD_UUID, "test.com/feed2", "invalid UUID format")) ); - when(subscriptionService.addSubscriptions(anyList(), eq(user.id()))) + when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId()))) .thenReturn(response); mockMvc.perform(post("/api/v1/subscriptions") - .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities()))) - .with(csrf()) + .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(List.of(dto)))) .andExpect(status().isBadRequest()) .andDo(document("subscriptions-bulk-create-failure", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("The access token used to authenticate the user") + ), responseFields( fieldWithPath("success[]").description("List of feed URLs successfully added").type(JsonFieldType.ARRAY).ignored(), fieldWithPath("failure[]").description("List of feed URLs that failed to add").type(JsonFieldType.ARRAY), @@ -281,20 +350,31 @@ void createUserSubscription_shouldReturnFailure() throws Exception { } @Test - @WithMockUser(username = "alice") - void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception { - CustomUserDetails user = new CustomUserDetails( - 1L, - UUID.randomUUID(), - "alice", - "alice@test.com", - Set.of(UserRoles.USER) - ); + void unsubscribingWithAnonymousUser_shouldReturnUnauthorized() throws Exception { + mockMvc.perform(post("/api/v1/subscriptions/{uuid}/unsubscribe", UUID.randomUUID()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "user") + void unsubscribingNonexistentEntity_shouldReturnNotFound() throws Exception { + when(subscriptionService.unsubscribeUserFromFeed(any(UUID.class), anyLong())) + .thenThrow(new EntityNotFoundException()); + + mockMvc.perform(post("/api/v1/subscriptions/{uuid}/unsubscribe", UUID.randomUUID()) + .header("Authorization", "Bearer " + accessToken) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + @Test + @WithMockUser(username = "user") + void unsubscribe_shouldReturnUpdatedSubscription() throws Exception { UUID subscriptionUuid = UUID.randomUUID(); boolean newStatus = false; - UserSubscriptionDto updatedSubscription = new UserSubscriptionDto( + SubscriptionDTO.UserSubscriptionDTO updatedSubscription = new SubscriptionDTO.UserSubscriptionDTO( subscriptionUuid, "test.com/feed1", Instant.now(), @@ -302,30 +382,32 @@ void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception newStatus ); - when(subscriptionService.unsubscribeUserFromFeed(subscriptionUuid, user.id())) + when(subscriptionService.unsubscribeUserFromFeed(subscriptionUuid, mockUser.getId())) .thenReturn(updatedSubscription); // Act & Assert - mockMvc.perform(RestDocumentationRequestBuilders.post("/api/v1/subscriptions/{uuid}/unsubscribe", subscriptionUuid) - .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities()))) - .with(csrf().asHeader()) + mockMvc.perform(post("/api/v1/subscriptions/{uuid}/unsubscribe", subscriptionUuid) + .header("Authorization", "Bearer " + accessToken) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.uuid").value(subscriptionUuid.toString())) .andExpect(jsonPath("$.feedUrl").value("test.com/feed1")) .andExpect(jsonPath("$.isSubscribed").value(false)) - .andDo(document("subscription-unsubscribe", + .andDo(document("subscriptionEntity-unsubscribe", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("The access token used to authenticate the user") + ), pathParameters( - parameterWithName("uuid").description("UUID of the subscription to update") + parameterWithName("uuid").description("UUID of the subscriptionEntity to update") ), responseFields( - fieldWithPath("uuid").description("The UUID of the subscription").type(JsonFieldType.STRING), - fieldWithPath("feedUrl").description("The feed URL of the subscription").type(JsonFieldType.STRING), - fieldWithPath("createdAt").description("When the subscription was created").type(JsonFieldType.STRING), - fieldWithPath("updatedAt").description("When the subscription was last updated").type(JsonFieldType.STRING), - fieldWithPath("isSubscribed").description("The updated subscription status").type(JsonFieldType.BOOLEAN) + fieldWithPath("uuid").description("The UUID of the subscriptionEntity").type(JsonFieldType.STRING), + fieldWithPath("feedUrl").description("The feed URL of the subscriptionEntity").type(JsonFieldType.STRING), + fieldWithPath("createdAt").description("When the subscriptionEntity was created").type(JsonFieldType.STRING), + fieldWithPath("updatedAt").description("When the subscriptionEntity was last updated").type(JsonFieldType.STRING), + fieldWithPath("isSubscribed").description("The updated subscriptionEntity status").type(JsonFieldType.BOOLEAN) ) )); } diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java similarity index 56% rename from src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java rename to src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java index c8b04a0..ad69b27 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java @@ -2,14 +2,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.openpodcastapi.opa.config.JwtAuthenticationFilter; -import org.openpodcastapi.opa.subscription.dto.UserSubscriptionDto; -import org.openpodcastapi.opa.subscription.mapper.UserSubscriptionMapper; -import org.openpodcastapi.opa.subscription.mapper.UserSubscriptionMapperImpl; -import org.openpodcastapi.opa.subscription.model.Subscription; -import org.openpodcastapi.opa.subscription.model.UserSubscription; -import org.openpodcastapi.opa.subscription.repository.UserSubscriptionRepository; -import org.openpodcastapi.opa.user.model.User; +import org.openpodcastapi.opa.subscription.*; +import org.openpodcastapi.opa.user.UserEntity; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -22,22 +16,19 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = UserSubscriptionMapperImpl.class) -class UserSubscriptionMapperTest { +class UserSubscriptionEntityMapperTest { @Autowired private UserSubscriptionMapper mapper; @MockitoBean private UserSubscriptionRepository userSubscriptionRepository; - @MockitoBean - private JwtAuthenticationFilter filter; - - /// Tests that a [UserSubscription] entity maps to a [UserSubscriptionDto] representation + /// Tests that a [UserSubscriptionEntity] entity maps to a [SubscriptionDTO.UserSubscriptionDTO] representation @Test void testToDto() { final Instant timestamp = Instant.now(); final UUID uuid = UUID.randomUUID(); - User user = User.builder() + UserEntity userEntity = UserEntity.builder() .uuid(UUID.randomUUID()) .username("test") .email("test@test.test") @@ -45,30 +36,30 @@ void testToDto() { .updatedAt(timestamp) .build(); - Subscription subscription = Subscription.builder() + SubscriptionEntity subscriptionEntity = SubscriptionEntity.builder() .uuid(UUID.randomUUID()) .feedUrl("test.com/feed1") .createdAt(timestamp) .updatedAt(timestamp) .build(); - UserSubscription userSubscription = UserSubscription.builder() + UserSubscriptionEntity userSubscriptionEntity = UserSubscriptionEntity.builder() .uuid(uuid) - .user(user) - .subscription(subscription) + .user(userEntity) + .subscription(subscriptionEntity) .isSubscribed(true) .createdAt(timestamp) .updatedAt(timestamp) .build(); - UserSubscriptionDto dto = mapper.toDto(userSubscription); + SubscriptionDTO.UserSubscriptionDTO dto = mapper.toDto(userSubscriptionEntity); assertNotNull(dto); - // The DTO should inherit the feed URL from the Subscription - assertEquals(subscription.getFeedUrl(), dto.feedUrl()); + // The DTO should inherit the feed URL from the SubscriptionEntity + assertEquals(subscriptionEntity.getFeedUrl(), dto.feedUrl()); - // The DTO should use the Subscription's UUID rather than the UserSubscription's - assertEquals(subscription.getUuid(), dto.uuid()); + // The DTO should use the SubscriptionEntity's UUID rather than the UserSubscriptionEntity's + assertEquals(subscriptionEntity.getUuid(), dto.uuid()); assertTrue(dto.isSubscribed()); } } diff --git a/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java new file mode 100644 index 0000000..8e85b97 --- /dev/null +++ b/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java @@ -0,0 +1,57 @@ +package org.openpodcastapi.opa.user; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +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 = UserMapperImpl.class) +class UserEntityMapperTest { + @Autowired + private UserMapper mapper; + + @MockitoBean + private UserRepository userRepository; + + /// Tests that a [UserEntity] entity maps to a [UserDTO.UserResponseDTO] representation + @Test + 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(); + + UserDTO.UserResponseDTO dto = mapper.toDto(userEntity); + assertNotNull(dto); + assertEquals(userEntity.getUuid(), dto.uuid()); + assertEquals(userEntity.getUsername(), dto.username()); + assertEquals(userEntity.getEmail(), dto.email()); + assertEquals(userEntity.getCreatedAt(), dto.createdAt()); + assertEquals(userEntity.getUpdatedAt(), dto.updatedAt()); + } + + /// Tests that a [UserDTO.CreateUserDTO] maps to a [UserEntity] entity + @Test + void testToEntity() { + UserDTO.CreateUserDTO dto = new UserDTO.CreateUserDTO("test", "testPassword", "test@test.test"); + UserEntity userEntity = mapper.toEntity(dto); + + assertNotNull(userEntity); + assertEquals(dto.email(), userEntity.getEmail()); + assertEquals(dto.username(), userEntity.getUsername()); + assertNull(userEntity.getPassword()); + } +} diff --git a/src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java b/src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java deleted file mode 100644 index 0c1ef0d..0000000 --- a/src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.openpodcastapi.opa.user; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.openpodcastapi.opa.user.dto.CreateUserDto; -import org.openpodcastapi.opa.user.dto.UserDto; -import org.openpodcastapi.opa.user.mapper.UserMapper; -import org.openpodcastapi.opa.user.mapper.UserMapperImpl; -import org.openpodcastapi.opa.user.model.User; -import org.openpodcastapi.opa.user.repository.UserRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.context.ContextConfiguration; -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 = UserMapperImpl.class) -class UserMapperSpringTest { - @Autowired - private UserMapper mapper; - - @MockitoBean - private UserRepository userRepository; - - /// Tests that a [User] entity maps to a [UserDto] representation - @Test - void testToDto() { - final Instant timestamp = Instant.now(); - final UUID uuid = UUID.randomUUID(); - User user = User.builder() - .uuid(uuid) - .username("test") - .email("test@test.test") - .createdAt(timestamp) - .updatedAt(timestamp) - .build(); - - UserDto dto = mapper.toDto(user); - assertNotNull(dto); - assertEquals(user.getUuid(), dto.uuid()); - assertEquals(user.getUsername(), dto.username()); - assertEquals(user.getEmail(), dto.email()); - assertEquals(user.getCreatedAt(), dto.createdAt()); - assertEquals(user.getUpdatedAt(), dto.updatedAt()); - } - - /// Tests that a [CreateUserDto] maps to a [User] entity - @Test - void testToEntity() { - CreateUserDto dto = new CreateUserDto("test", "testPassword", "test@test.test"); - User user = mapper.toEntity(dto); - - assertNotNull(user); - assertEquals(dto.email(), user.getEmail()); - assertEquals(dto.username(), user.getUsername()); - assertNull(user.getPassword()); - } -} diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java index 2d102e4..218c612 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java @@ -2,8 +2,7 @@ import lombok.extern.log4j.Log4j2; import org.junit.jupiter.api.Test; -import org.openpodcastapi.opa.user.dto.UserDto; -import org.openpodcastapi.opa.user.service.UserService; +import org.openpodcastapi.opa.security.TokenService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -19,10 +18,14 @@ 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; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; @@ -42,15 +45,43 @@ class UserRestControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private TokenService tokenService; + + @MockitoBean + private UserRepository userRepository; + @MockitoBean private UserService userService; @Test - @WithMockUser(roles = {"USER", "ADMIN"}) + void getAllUsers_shouldReturn401_forAnonymousUser() throws Exception { + mockMvc.perform(get("/api/v1/users") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @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.getUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); + + String accessToken = tokenService.generateAccessToken(mockUser); + final Instant createdDate = Instant.now(); - final UserDto user1 = new UserDto( + final UserDTO.UserResponseDTO user1 = new UserDTO.UserResponseDTO( UUID.randomUUID(), "alice", "alice@test.com", @@ -58,7 +89,7 @@ void getAllUsers_shouldReturn200_andList() throws Exception { createdDate ); - final UserDto user2 = new UserDto( + final UserDTO.UserResponseDTO user2 = new UserDTO.UserResponseDTO( UUID.randomUUID(), "bob", "bob@test.com", @@ -67,11 +98,12 @@ void getAllUsers_shouldReturn200_andList() throws Exception { ); // Mock the service call to return users - PageImpl page = new PageImpl<>(List.of(user1, user2), PageRequest.of(0, 2), 2); + PageImpl page = new PageImpl<>(List.of(user1, user2), PageRequest.of(0, 2), 2); when(userService.getAllUsers(any())).thenReturn(page); // Perform the test for the admin role mockMvc.perform(get("/api/v1/users") + .header("Authorization", "Bearer " + accessToken) .accept(MediaType.APPLICATION_JSON) .param("page", "0") .param("size", "20")) @@ -79,16 +111,19 @@ void getAllUsers_shouldReturn200_andList() throws Exception { .andDo(document("users-list", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("The access token used to authenticate the user") + ), queryParameters( parameterWithName("page").description("The page number to fetch").optional(), parameterWithName("size").description("The number of results to include on each page").optional() ), responseFields( - fieldWithPath("users[].uuid").description("The user's UUID").type(JsonFieldType.STRING), - fieldWithPath("users[].username").description("The user's username").type(JsonFieldType.STRING), - fieldWithPath("users[].email").description("User email address").type(JsonFieldType.STRING), - fieldWithPath("users[].createdAt").description("The date at which the user was created").type(JsonFieldType.STRING), - fieldWithPath("users[].updatedAt").description("The date at which the user was last updated").type(JsonFieldType.STRING), + fieldWithPath("users[].uuid").description("The userEntity's UUID").type(JsonFieldType.STRING), + fieldWithPath("users[].username").description("The userEntity's username").type(JsonFieldType.STRING), + fieldWithPath("users[].email").description("UserEntity email address").type(JsonFieldType.STRING), + fieldWithPath("users[].createdAt").description("The date at which the userEntity was created").type(JsonFieldType.STRING), + fieldWithPath("users[].updatedAt").description("The date at which the userEntity was last updated").type(JsonFieldType.STRING), fieldWithPath("page").description("Current page number").type(JsonFieldType.NUMBER), fieldWithPath("size").description("Page size").type(JsonFieldType.NUMBER), fieldWithPath("totalElements").description("Total number of users").type(JsonFieldType.NUMBER), @@ -101,14 +136,29 @@ void getAllUsers_shouldReturn200_andList() throws Exception { } @Test - @WithMockUser(roles = "USER") - // Mock the user with a "USER" role + @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.getUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); + + String accessToken = tokenService.generateAccessToken(mockUser); + mockMvc.perform(get("/api/v1/users") + .header("Authorization", "Bearer " + accessToken) .accept(MediaType.APPLICATION_JSON) .param("page", "0") .param("size", "20")) - .andExpect(status().isForbidden()) // Expect 403 for the user role + .andExpect(status().isForbidden()) .andDo(document("users-list", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()),