From 464d14fb2bcb56d211594de0d7433963ac95b66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 02:16:58 +0100 Subject: [PATCH 01/12] Consolidate some files --- README.md | 2 +- src/docs/index.adoc | 1 + src/docs/subscriptions.adoc | 18 +-- .../opa/auth/ApiAuthController.java | 4 +- .../opa/config/JwtAuthenticationFilter.java | 6 +- .../opa/config/SecurityConfig.java | 2 +- .../opa/config/TemplateConfig.java | 13 -- .../openpodcastapi/opa/config/WebConfig.java | 7 + .../opa/helpers/UUIDHelper.java | 2 +- .../opa/security/RefreshToken.java | 2 +- .../opa/security/RefreshTokenRepository.java | 2 +- .../opa/security/TokenService.java | 2 +- .../opa/service/CustomUserDetails.java | 2 +- .../opa/service/CustomUserDetailsService.java | 4 +- .../opa/subscription/SubscriptionDTO.java | 94 +++++++++++ ...scription.java => SubscriptionEntity.java} | 19 +-- .../{mapper => }/SubscriptionMapper.java | 6 +- .../SubscriptionRepository.java | 7 +- .../SubscriptionRestController.java | 47 +++--- .../opa/subscription/SubscriptionService.java | 147 ++++++++++++++++++ ...ption.java => UserSubscriptionEntity.java} | 22 +-- .../subscription/UserSubscriptionMapper.java | 11 ++ .../UserSubscriptionRepository.java | 18 +++ .../dto/BulkSubscriptionResponse.java | 13 -- .../dto/SubscriptionCreateDto.java | 15 -- .../dto/SubscriptionFailureDto.java | 16 -- .../subscription/dto/SubscriptionPageDto.java | 39 ----- .../subscription/dto/UserSubscriptionDto.java | 23 --- .../mapper/UserSubscriptionMapper.java | 13 -- .../UserSubscriptionRepository.java | 19 --- .../service/SubscriptionService.java | 35 ----- .../service/UserSubscriptionService.java | 140 ----------------- .../opa/ui/controller/UiAuthController.java | 8 +- .../opa/user/{model => }/User.java | 27 +--- .../org/openpodcastapi/opa/user/UserDTO.java | 74 +++++++++ .../opa/user/{mapper => }/UserMapper.java | 9 +- .../user/{repository => }/UserRepository.java | 3 +- .../{controller => }/UserRestController.java | 16 +- .../opa/user/{model => }/UserRoles.java | 2 +- .../openpodcastapi/opa/user/UserService.java | 69 ++++++++ .../opa/user/dto/CreateUserDto.java | 17 -- .../openpodcastapi/opa/user/dto/UserDto.java | 22 --- .../opa/user/dto/UserPageDto.java | 39 ----- .../opa/user/service/UserService.java | 124 --------------- .../opa/util/AdminUserInitializer.java | 6 +- .../openpodcastapi/opa/auth/AuthApiTest.java | 6 +- ...SubscriptionEntityRestControllerTest.java} | 97 ++++++------ ...erSubscriptionEntityEntityMapperTest.java} | 29 ++-- .../opa/user/UserMapperSpringTest.java | 14 +- .../opa/user/UserRestControllerTest.java | 8 +- 50 files changed, 574 insertions(+), 747 deletions(-) delete mode 100644 src/main/java/org/openpodcastapi/opa/config/TemplateConfig.java create mode 100644 src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java rename src/main/java/org/openpodcastapi/opa/subscription/{model/Subscription.java => SubscriptionEntity.java} (80%) rename src/main/java/org/openpodcastapi/opa/subscription/{mapper => }/SubscriptionMapper.java (67%) rename src/main/java/org/openpodcastapi/opa/subscription/{repository => }/SubscriptionRepository.java (56%) rename src/main/java/org/openpodcastapi/opa/subscription/{controller => }/SubscriptionRestController.java (60%) create mode 100644 src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java rename src/main/java/org/openpodcastapi/opa/subscription/{model/UserSubscription.java => UserSubscriptionEntity.java} (79%) create mode 100644 src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionMapper.java create mode 100644 src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/dto/BulkSubscriptionResponse.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionCreateDto.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionFailureDto.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/dto/SubscriptionPageDto.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/dto/UserSubscriptionDto.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/mapper/UserSubscriptionMapper.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/service/SubscriptionService.java delete mode 100644 src/main/java/org/openpodcastapi/opa/subscription/service/UserSubscriptionService.java rename src/main/java/org/openpodcastapi/opa/user/{model => }/User.java (76%) create mode 100644 src/main/java/org/openpodcastapi/opa/user/UserDTO.java rename src/main/java/org/openpodcastapi/opa/user/{mapper => }/UserMapper.java (64%) rename src/main/java/org/openpodcastapi/opa/user/{repository => }/UserRepository.java (84%) rename src/main/java/org/openpodcastapi/opa/user/{controller => }/UserRestController.java (69%) rename src/main/java/org/openpodcastapi/opa/user/{model => }/UserRoles.java (81%) create mode 100644 src/main/java/org/openpodcastapi/opa/user/UserService.java delete mode 100644 src/main/java/org/openpodcastapi/opa/user/dto/CreateUserDto.java delete mode 100644 src/main/java/org/openpodcastapi/opa/user/dto/UserDto.java delete mode 100644 src/main/java/org/openpodcastapi/opa/user/dto/UserPageDto.java delete mode 100644 src/main/java/org/openpodcastapi/opa/user/service/UserService.java rename src/test/java/org/openpodcastapi/opa/subscriptions/{SubscriptionRestControllerTest.java => SubscriptionEntityRestControllerTest.java} (79%) rename src/test/java/org/openpodcastapi/opa/subscriptions/{UserSubscriptionMapperTest.java => UserSubscriptionEntityEntityMapperTest.java} (62%) diff --git a/README.md b/README.md index e689e77..d3264fe 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,4 @@ This server is intended as a reference implementation for testing purposes and e This server is a work in progress. It will be updated as new specs are created. -At the current time, the server supports only simple user registration and subscription management. +At the current time, the server supports only simple user registration and subscriptionEntity management. diff --git a/src/docs/index.adoc b/src/docs/index.adoc index e139a49..466f1f6 100644 --- a/src/docs/index.adoc +++ b/src/docs/index.adoc @@ -8,5 +8,6 @@ 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..28d98de 100644 --- a/src/docs/subscriptions.adoc +++ b/src/docs/subscriptions.adoc @@ -2,7 +2,7 @@ :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 @@ -18,7 +18,7 @@ POST /api/v1/users [[actions-subscriptions-create]] === 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 subscriptionEntity 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'] @@ -47,16 +47,16 @@ operation::subscriptions-list[snippets='query-parameters,curl-request,response-f 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='path-parameters,curl-request,response-fields,http-response'] -[[actions-subscription-update]] +[[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='path-parameters,curl-request,response-fields,http-response'] diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java index 909dc71..0d15ffc 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java @@ -6,8 +6,8 @@ import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.config.JwtService; 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.User; +import org.openpodcastapi.opa.user.UserRepository; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; diff --git a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java b/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java index 4b02aea..77ac104 100644 --- a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java +++ b/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java @@ -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.User; +import org.openpodcastapi.opa.user.UserRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -69,7 +69,7 @@ protected void doFilterInternal(HttpServletRequest req, @Nonnull HttpServletResp throws ServletException, IOException { // Don't apply the check on the auth endpoints - if (req.getRequestURI().startsWith("/api/auth/")) { + if (req.getRequestURI().startsWith("/api/auth/") || req.getRequestURI().startsWith("/docs")) { chain.doFilter(req, res); return; } diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java index f916d91..7af68b6 100644 --- a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java @@ -32,7 +32,7 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws .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("/", "/login", "/logout-confirm", "/register", "/docs**", "/css/**", "/js/**", "/images/**", "/favicon.ico", "/api/auth/**").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..8d0c747 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; @@ -20,4 +22,9 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { .addResourceHandler("/docs/**") .addResourceLocations("classpath:/docs/"); } + + @Bean + public LayoutDialect layoutDialect() { + return new LayoutDialect(); + } } diff --git a/src/main/java/org/openpodcastapi/opa/helpers/UUIDHelper.java b/src/main/java/org/openpodcastapi/opa/helpers/UUIDHelper.java index 37bd6b0..66b4aa8 100644 --- a/src/main/java/org/openpodcastapi/opa/helpers/UUIDHelper.java +++ b/src/main/java/org/openpodcastapi/opa/helpers/UUIDHelper.java @@ -52,7 +52,7 @@ public static UUID getFeedUUID(String feedUrl) { return generator.generate(sanitizedFeedUrl); } - /// Validates that a supplied subscription UUID has been calculated properly + /// Validates that a supplied subscriptionEntity UUID has been calculated properly /// /// @param feedUrl the URL of the podcast feed /// @param suppliedUUID the UUID to validate diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java b/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java index ecbd85d..6a30b3f 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshToken.java @@ -2,7 +2,7 @@ import jakarta.persistence.*; import lombok.*; -import org.openpodcastapi.opa.user.model.User; +import org.openpodcastapi.opa.user.User; import java.time.Instant; diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java index 56406e4..236ed31 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java @@ -1,6 +1,6 @@ package org.openpodcastapi.opa.security; -import org.openpodcastapi.opa.user.model.User; +import org.openpodcastapi.opa.user.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/org/openpodcastapi/opa/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java index 3cb7b31..aa1453d 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.User; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; 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..c0959b6 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.User; +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; 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..b3d4f38 --- /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 subscriptionEntity + /// + /// @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 subscriptionEntity to a given feed + /// + /// @param uuid the feed UUID + /// @param feedUrl the feed URL + /// @param createdAt the date at which the subscriptionEntity link was created + /// @param updatedAt the date at which the subscriptionEntity 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 subscriptionEntity 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 subscriptionEntity creation + /// + /// @param uuid the UUID of the failed subscriptionEntity + /// @param feedUrl the feed URL of the failed subscriptionEntity + /// @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 60% 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..10f3de8 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,7 +20,7 @@ @Log4j2 @RequestMapping("/api/v1/subscriptions") public class SubscriptionRestController { - private final UserSubscriptionService service; + private final SubscriptionService service; /// Returns all subscriptions for a given user /// @@ -36,9 +31,9 @@ public class SubscriptionRestController { @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 + /// Returns a single subscriptionEntity entry by UUID /// /// @param uuid the UUID value to query for - /// @return the subscription entity + /// @return the subscriptionEntity entity /// @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()); + // Fetch the subscriptionEntity, throw an EntityNotFoundException if this fails + 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 + /// Updates the subscriptionEntity status of a subscriptionEntity for a given user /// - /// @param uuid the UUID of the subscription to update - /// @return the updated subscription entity + /// @param uuid the UUID of the subscriptionEntity to update + /// @return the updated subscriptionEntity entity /// @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 UserSubscriptions 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 [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..148af9d --- /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.User; +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 subscriptionEntity 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 subscriptionEntity with UUID {}", dto.uuid()); + return subscriptionRepository.save(subscriptionMapper.toEntity(dto)); + }); + } + + /// Fetches a single subscriptionEntity for an authenticated user, if it exists + /// + /// @param subscriptionUuid the UUID of the subscriptionEntity + /// @param userId the database ID of the user + /// @return a [SubscriptionDTO.UserSubscriptionDTO] of the user subscriptionEntity + /// @throws EntityNotFoundException if no entry is found + @Transactional(readOnly = true) + public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid(UUID subscriptionUuid, Long userId) { + log.debug("Fetching subscriptionEntity {} for user {}", subscriptionUuid, userId); + UserSubscriptionEntity subscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionUuid) + .orElseThrow(() -> new EntityNotFoundException("subscriptionEntity not found for user")); + + log.debug("SubscriptionEntity {} 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 [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 subscriptionEntity to the database + /// If an existing entry is found for the user and subscriptionEntity, 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 subscriptionEntity link + /// @throws EntityNotFoundException if no matching user is found + protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(SubscriptionEntity subscriptionEntity, Long userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("user not found")); + log.debug("{}", user); + + UserSubscriptionEntity newSubscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionEntity.getUuid()).orElseGet(() -> { + log.debug("Creating new user subscriptionEntity for user {} and subscriptionEntity {}", userId, subscriptionEntity.getUuid()); + UserSubscriptionEntity createdSubscription = new UserSubscriptionEntity(); + createdSubscription.setIsSubscribed(true); + createdSubscription.setUser(user); + 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 subscriptionEntity 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 subscriptionEntity for a given user + /// + /// @param feedUUID the UUID of the subscriptionEntity 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 subscriptionEntity 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 79% 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..24a1590 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.User; 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; - @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/ui/controller/UiAuthController.java b/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java index 8e678c4..819be0c 100644 --- a/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java @@ -3,8 +3,8 @@ 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; @@ -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/user/model/User.java b/src/main/java/org/openpodcastapi/opa/user/User.java similarity index 76% rename from src/main/java/org/openpodcastapi/opa/user/model/User.java rename to src/main/java/org/openpodcastapi/opa/user/User.java index 5dcee1e..73a838b 100644 --- a/src/main/java/org/openpodcastapi/opa/user/model/User.java +++ b/src/main/java/org/openpodcastapi/opa/user/User.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 { @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/UserDTO.java b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java new file mode 100644 index 0000000..14f9b08 --- /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/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..d33f6e9 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(User user); @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); + User toEntity(UserDTO.CreateUserDTO dto); } diff --git a/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java similarity index 84% rename from src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java rename to src/main/java/org/openpodcastapi/opa/user/UserRepository.java index 43019fb..4affa96 100644 --- a/src/main/java/org/openpodcastapi/opa/user/repository/UserRepository.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java @@ -1,6 +1,5 @@ -package org.openpodcastapi.opa.user.repository; +package org.openpodcastapi.opa.user; -import org.openpodcastapi.opa.user.model.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; 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 69% 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..f324b11 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; @@ -24,17 +20,17 @@ public class UserRestController { @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); } @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); 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..064ab61 --- /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 DTO representation of the user + /// @throws DataIntegrityViolationException if a user with a matching username or email address exists already + @Transactional + public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) throws DataIntegrityViolationException { + // 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); + } + + @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 { + 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/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/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..ff50672 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.User; +import org.openpodcastapi.opa.user.UserRoles; +import org.openpodcastapi.opa.user.UserRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; diff --git a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java index c7fdcff..8053987 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.User; +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; diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java similarity index 79% rename from src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java rename to src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java index 6c0c8fe..911c554 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java @@ -3,12 +3,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; 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.subscription.SubscriptionDTO; +import org.openpodcastapi.opa.subscription.SubscriptionService; +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; @@ -47,7 +44,7 @@ @ActiveProfiles("test") @AutoConfigureMockMvc @AutoConfigureRestDocs(outputDir = "target/generated-snippets") -class SubscriptionRestControllerTest { +class SubscriptionEntityRestControllerTest { @Autowired private MockMvc mockMvc; @@ -55,16 +52,16 @@ class SubscriptionRestControllerTest { private ObjectMapper objectMapper; @MockitoBean - private UserSubscriptionService subscriptionService; + private SubscriptionService subscriptionService; @Test @WithMockUser(username = "alice") void getAllSubscriptionsForUser_shouldReturnSubscriptions() 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(), true); - 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(), true); + Page page = new PageImpl<>(List.of(sub1, sub2)); when(subscriptionService.getAllActiveSubscriptionsForUser(eq(user.id()), any(Pageable.class))) .thenReturn(page); @@ -86,10 +83,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), @@ -110,9 +107,9 @@ void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws 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))) .thenReturn(page); @@ -133,22 +130,22 @@ 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); + SubscriptionDTO.UserSubscriptionDTO sub = new SubscriptionDTO.UserSubscriptionDTO(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), true); when(subscriptionService.getUserSubscriptionBySubscriptionUuid(subscriptionUuid, user.id())) .thenReturn(sub); mockMvc.perform(get("/api/v1/subscriptions/{uuid}", subscriptionUuid) .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities())))) .andExpect(status().isOk()) - .andDo(document("subscription-get", + .andDo(document("subscriptionEntity-get", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), 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) @@ -165,12 +162,12 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { 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()))) @@ -186,15 +183,15 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), 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), @@ -212,10 +209,10 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { 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() ); @@ -232,15 +229,15 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), 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()))); } @@ -252,11 +249,11 @@ void createUserSubscription_shouldReturnFailure() throws Exception { 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()))) @@ -294,7 +291,7 @@ void updateSubscriptionStatus_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(), @@ -314,18 +311,18 @@ void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception .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()), 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/UserSubscriptionEntityEntityMapperTest.java similarity index 62% rename from src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java rename to src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityEntityMapperTest.java index c8b04a0..a0e6eae 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityEntityMapperTest.java @@ -3,13 +3,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.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -22,7 +17,7 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = UserSubscriptionMapperImpl.class) -class UserSubscriptionMapperTest { +class UserSubscriptionEntityEntityMapperTest { @Autowired private UserSubscriptionMapper mapper; @@ -32,7 +27,7 @@ class UserSubscriptionMapperTest { @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(); @@ -45,30 +40,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) + .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/UserMapperSpringTest.java b/src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java index 0c1ef0d..b67d9c7 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java @@ -2,12 +2,6 @@ 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; @@ -27,7 +21,7 @@ class UserMapperSpringTest { @MockitoBean private UserRepository userRepository; - /// Tests that a [User] entity maps to a [UserDto] representation + /// Tests that a [User] entity maps to a [UserDTO.UserResponseDTO] representation @Test void testToDto() { final Instant timestamp = Instant.now(); @@ -40,7 +34,7 @@ void testToDto() { .updatedAt(timestamp) .build(); - UserDto dto = mapper.toDto(user); + UserDTO.UserResponseDTO dto = mapper.toDto(user); assertNotNull(dto); assertEquals(user.getUuid(), dto.uuid()); assertEquals(user.getUsername(), dto.username()); @@ -49,10 +43,10 @@ void testToDto() { assertEquals(user.getUpdatedAt(), dto.updatedAt()); } - /// Tests that a [CreateUserDto] maps to a [User] entity + /// Tests that a [UserDTO.CreateUserDTO] maps to a [User] entity @Test void testToEntity() { - CreateUserDto dto = new CreateUserDto("test", "testPassword", "test@test.test"); + UserDTO.CreateUserDTO dto = new UserDTO.CreateUserDTO("test", "testPassword", "test@test.test"); User user = mapper.toEntity(dto); assertNotNull(user); diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java index 2d102e4..7a924c1 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java @@ -2,8 +2,6 @@ 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.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -50,7 +48,7 @@ class UserRestControllerTest { void getAllUsers_shouldReturn200_andList() throws Exception { 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 +56,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,7 +65,7 @@ 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 From f51855821b2fc219ed5149b081bed04cc2092da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 02:18:06 +0100 Subject: [PATCH 02/12] Fix replacement in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d3264fe..e689e77 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,4 @@ This server is intended as a reference implementation for testing purposes and e This server is a work in progress. It will be updated as new specs are created. -At the current time, the server supports only simple user registration and subscriptionEntity management. +At the current time, the server supports only simple user registration and subscription management. From 5fb4f71eff57502807cba80b72c1939347a1beee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 02:47:08 +0100 Subject: [PATCH 03/12] Improve JSON formatting in error messages --- .../openpodcastapi/opa/auth/ApiAuthController.java | 8 ++++---- .../opa/auth/{DTOs.java => AuthDTO.java} | 14 ++++++++++++-- .../opa/auth/JwtAccessDeniedHandler.java | 11 +++-------- .../opa/auth/JwtAuthenticationEntryPoint.java | 13 +++---------- .../org/openpodcastapi/opa/util/JSONFormatter.java | 12 ++++++++++++ 5 files changed, 34 insertions(+), 24 deletions(-) rename src/main/java/org/openpodcastapi/opa/auth/{DTOs.java => AuthDTO.java} (82%) create mode 100644 src/main/java/org/openpodcastapi/opa/util/JSONFormatter.java diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java index 0d15ffc..b383f2b 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java @@ -28,7 +28,7 @@ public class ApiAuthController { private final AuthenticationManager authenticationManager; @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()) @@ -45,13 +45,13 @@ public ResponseEntity login(@RequestBody @NotNull DTO String refreshToken = tokenService.generateRefreshToken(user); // 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(jwtService.getExpirationTime())); return ResponseEntity.ok(response); } @PostMapping("/api/auth/refresh") - public ResponseEntity getRefreshToken(@RequestBody @NotNull DTOs.RefreshTokenRequest refreshTokenRequest) { + public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) { User targetUser = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); // Validate the existing refresh token @@ -61,7 +61,7 @@ public ResponseEntity getRefreshToken(@RequestBody @N String newAccessToken = tokenService.generateAccessToken(user); // 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(jwtService.getExpirationTime())); return ResponseEntity.ok(response); } diff --git a/src/main/java/org/openpodcastapi/opa/auth/DTOs.java b/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java similarity index 82% rename from src/main/java/org/openpodcastapi/opa/auth/DTOs.java rename to src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java index 5acf7f8..0c8bf6e 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/DTOs.java +++ b/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java @@ -3,8 +3,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotNull; -/// All DTOs for auth methods -public class DTOs { +/// All data transfer objects for auth methods +public class AuthDTO { /// A DTO representing an API login request /// /// @param username the user's username @@ -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..e04d5bc 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.openpodcastapi.opa.util.JSONFormatter; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; @@ -11,7 +12,6 @@ @Component public class JwtAccessDeniedHandler implements AccessDeniedHandler { - @Override public void handle(HttpServletRequest request, HttpServletResponse response, @@ -23,13 +23,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(JSONFormatter.parseDataToJSON(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..63e7f5d 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationEntryPoint.java @@ -2,7 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.log4j.Log4j2; +import org.openpodcastapi.opa.util.JSONFormatter; import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -11,7 +11,6 @@ import java.io.IOException; @Component -@Log4j2 public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { /// Returns a 401 when a request is made without a valid bearer token @Override @@ -22,14 +21,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(JSONFormatter.parseDataToJSON(message)); } } diff --git a/src/main/java/org/openpodcastapi/opa/util/JSONFormatter.java b/src/main/java/org/openpodcastapi/opa/util/JSONFormatter.java new file mode 100644 index 0000000..f587226 --- /dev/null +++ b/src/main/java/org/openpodcastapi/opa/util/JSONFormatter.java @@ -0,0 +1,12 @@ +package org.openpodcastapi.opa.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class JSONFormatter { + public static String parseDataToJSON(Object item) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + + return objectMapper.writeValueAsString(item); + } +} From 2aa02d2c2db0862adb58d77df7aab2d8739cef3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 02:48:44 +0100 Subject: [PATCH 04/12] Remove unused UUID utility --- .../opa/helpers/UUIDHelper.java | 77 ---------------- .../opa/helpers/UUIDHelperTest.java | 88 ------------------- 2 files changed, 165 deletions(-) delete mode 100644 src/main/java/org/openpodcastapi/opa/helpers/UUIDHelper.java delete mode 100644 src/test/java/org/openpodcastapi/opa/helpers/UUIDHelperTest.java 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 66b4aa8..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 subscriptionEntity 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/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)); - } -} From 3dec2ad1ef48475d206dbfe2bb02550eed8ce231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 03:01:29 +0100 Subject: [PATCH 05/12] Remove unused UUID utility and clean up entity naming --- src/docs/subscriptions.adoc | 2 +- .../opa/auth/ApiAuthController.java | 14 +++--- .../opa/config/JwtAuthenticationFilter.java | 20 ++++---- ...reshToken.java => RefreshTokenEntity.java} | 6 +-- .../opa/security/RefreshTokenRepository.java | 6 +-- .../opa/security/TokenService.java | 38 +++++++-------- .../opa/service/CustomUserDetailsService.java | 18 +++---- .../SubscriptionRestController.java | 8 ++-- .../opa/subscription/SubscriptionService.java | 48 +++++++++---------- .../subscription/UserSubscriptionEntity.java | 4 +- .../opa/user/{User.java => UserEntity.java} | 2 +- .../openpodcastapi/opa/user/UserMapper.java | 4 +- .../opa/user/UserRepository.java | 8 ++-- .../openpodcastapi/opa/user/UserService.java | 18 +++---- .../opa/util/AdminUserInitializer.java | 4 +- .../openpodcastapi/opa/auth/AuthApiTest.java | 16 +++---- ... => UserSubscriptionEntityMapperTest.java} | 8 ++-- ...ingTest.java => UserEntityMapperTest.java} | 30 ++++++------ .../opa/user/UserRestControllerTest.java | 14 +++--- 19 files changed, 134 insertions(+), 134 deletions(-) rename src/main/java/org/openpodcastapi/opa/security/{RefreshToken.java => RefreshTokenEntity.java} (85%) rename src/main/java/org/openpodcastapi/opa/user/{User.java => UserEntity.java} (98%) rename src/test/java/org/openpodcastapi/opa/subscriptions/{UserSubscriptionEntityEntityMapperTest.java => UserSubscriptionEntityMapperTest.java} (93%) rename src/test/java/org/openpodcastapi/opa/user/{UserMapperSpringTest.java => UserEntityMapperTest.java} (57%) diff --git a/src/docs/subscriptions.adoc b/src/docs/subscriptions.adoc index 28d98de..9580474 100644 --- a/src/docs/subscriptions.adoc +++ b/src/docs/subscriptions.adoc @@ -18,7 +18,7 @@ POST /api/v1/users [[actions-subscriptions-create]] === Create subscriptions -When a user adds a subscriptionEntity 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. +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'] diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java index b383f2b..43da2fc 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java @@ -6,7 +6,7 @@ import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.config.JwtService; import org.openpodcastapi.opa.security.TokenService; -import org.openpodcastapi.opa.user.User; +import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; @@ -38,11 +38,11 @@ public ResponseEntity login(@RequestBody @NotNull 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 AuthDTO.LoginSuccessResponse response = new AuthDTO.LoginSuccessResponse(accessToken, refreshToken, String.valueOf(jwtService.getExpirationTime())); @@ -52,13 +52,13 @@ public ResponseEntity login(@RequestBody @NotNull @PostMapping("/api/auth/refresh") public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) { - User targetUser = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); + 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 AuthDTO.RefreshTokenResponse response = new AuthDTO.RefreshTokenResponse(newAccessToken, String.valueOf(jwtService.getExpirationTime())); diff --git a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java b/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java index 77ac104..1729332 100644 --- a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java +++ b/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java @@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.service.CustomUserDetails; -import org.openpodcastapi.opa.user.User; +import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; @@ -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( @@ -99,10 +99,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/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 6a30b3f..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.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 236ed31..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.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 aa1453d..cbfde43 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.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; @@ -38,32 +38,32 @@ private SecretKey key() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } - /// Generates an access token for a given user + /// Generates an access token for a given userEntity /// - /// @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()) .compact(); } - /// Generates a refresh token for a given user + /// Generates a refresh token for a given userEntity /// - /// @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(); @@ -72,22 +72,22 @@ public String generateRefreshToken(User user) { return raw; } - /// Validates the refresh token for a user and updates its expiry time + /// Validates the refresh token for a userEntity 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) { - // Only fetch refresh tokens for the requesting user - for (RefreshToken token : repository.findAllByUser(user)) { + /// @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 userEntity + 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 the userEntity to confirm the token is valid return updatedToken.getUser(); } } diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java index c0959b6..20bcdb6 100644 --- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java +++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java @@ -1,7 +1,7 @@ package org.openpodcastapi.opa.service; import lombok.RequiredArgsConstructor; -import org.openpodcastapi.opa.user.User; +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; @@ -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/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java index 10f3de8..3fd8f32 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java @@ -22,9 +22,9 @@ public class SubscriptionRestController { private final SubscriptionService service; - /// Returns all subscriptions for a given user + /// Returns all subscriptions for a given userEntity /// - /// @param user the [CustomUserDetails] of the authenticated user + /// @param user the [CustomUserDetails] of the authenticated userEntity /// @param pageable the [Pageable] pagination object /// @param includeUnsubscribed whether to include unsubscribed feeds in the response /// @return a paginated list of subscriptions @@ -67,7 +67,7 @@ public ResponseEntity getSubscriptionByUuid return new ResponseEntity<>(dto, HttpStatus.OK); } - /// Updates the subscriptionEntity status of a subscriptionEntity for a given user + /// Updates the subscriptionEntity status of a subscriptionEntity for a given userEntity /// /// @param uuid the UUID of the subscriptionEntity to update /// @return the updated subscriptionEntity entity @@ -86,7 +86,7 @@ public ResponseEntity unsubscribeUserFromFe return new ResponseEntity<>(dto, HttpStatus.OK); } - /// Bulk creates UserSubscriptions for a user. Creates new SubscriptionEntity objects if not already present + /// Bulk creates UserSubscriptions for a userEntity. Creates new SubscriptionEntity objects if not already present /// /// @param request a list of [SubscriptionDTO.SubscriptionCreateDTO] objects /// @return a [SubscriptionDTO.BulkSubscriptionResponseDTO] object diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java index 148af9d..421d8f8 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java @@ -3,7 +3,7 @@ import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.user.User; +import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -38,25 +38,25 @@ protected SubscriptionEntity fetchOrCreateSubscription(SubscriptionDTO.Subscript }); } - /// Fetches a single subscriptionEntity for an authenticated user, if it exists + /// Fetches a single subscriptionEntity for an authenticated userEntity, if it exists /// /// @param subscriptionUuid the UUID of the subscriptionEntity - /// @param userId the database ID of the user - /// @return a [SubscriptionDTO.UserSubscriptionDTO] of the user subscriptionEntity + /// @param userId the database ID of the userEntity + /// @return a [SubscriptionDTO.UserSubscriptionDTO] of the userEntity subscriptionEntity /// @throws EntityNotFoundException if no entry is found @Transactional(readOnly = true) public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid(UUID subscriptionUuid, Long userId) { - log.debug("Fetching subscriptionEntity {} for user {}", subscriptionUuid, userId); + log.debug("Fetching subscriptionEntity {} for userEntity {}", subscriptionUuid, userId); UserSubscriptionEntity subscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionUuid) - .orElseThrow(() -> new EntityNotFoundException("subscriptionEntity not found for user")); + .orElseThrow(() -> new EntityNotFoundException("subscriptionEntity not found for userEntity")); - log.debug("SubscriptionEntity {} for user {} found", subscriptionUuid, userId); + log.debug("SubscriptionEntity {} for userEntity {} found", subscriptionUuid, userId); return userSubscriptionMapper.toDto(subscription); } - /// Gets all subscriptions for the authenticated user + /// Gets all subscriptions for the authenticated userEntity /// - /// @param userId the database ID of the authenticated user + /// @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) { @@ -65,9 +65,9 @@ public Page getAllSubscriptionsForUser(Long .map(userSubscriptionMapper::toDto); } - /// Gets all active subscriptions for the authenticated user + /// Gets all active subscriptions for the authenticated userEntity /// - /// @param userId the database ID of the authenticated user + /// @param userId the database ID of the authenticated userEntity /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects @Transactional(readOnly = true) public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) { @@ -75,22 +75,22 @@ public Page getAllActiveSubscriptionsForUse return userSubscriptionRepository.findAllByUserIdAndIsSubscribedTrue(userId, pageable).map(userSubscriptionMapper::toDto); } - /// Persists a new user subscriptionEntity to the database - /// If an existing entry is found for the user and subscriptionEntity, the `isSubscribed` property is set to `true` + /// Persists a new userEntity subscriptionEntity to the database + /// If an existing entry is found for the userEntity and subscriptionEntity, the `isSubscribed` property is set to `true` /// /// @param subscriptionEntity the target subscriptionEntity - /// @param userId the ID of the target user + /// @param userId the ID of the target userEntity /// @return a [SubscriptionDTO.UserSubscriptionDTO] representation of the subscriptionEntity link - /// @throws EntityNotFoundException if no matching user is found + /// @throws EntityNotFoundException if no matching userEntity is found protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(SubscriptionEntity subscriptionEntity, Long userId) { - User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("user not found")); - log.debug("{}", user); + 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 user subscriptionEntity for user {} and subscriptionEntity {}", userId, subscriptionEntity.getUuid()); + log.debug("Creating new userEntity subscriptionEntity for userEntity {} and subscriptionEntity {}", userId, subscriptionEntity.getUuid()); UserSubscriptionEntity createdSubscription = new UserSubscriptionEntity(); createdSubscription.setIsSubscribed(true); - createdSubscription.setUser(user); + createdSubscription.setUserEntity(userEntity); createdSubscription.setSubscription(subscriptionEntity); return userSubscriptionRepository.save(createdSubscription); }); @@ -99,10 +99,10 @@ protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(Subscripti return userSubscriptionMapper.toDto(userSubscriptionRepository.save(newSubscription)); } - /// Creates UserSubscriptionEntity links in bulk. If the SubscriptionEntity isn't already in the system, this is added before the user is subscribed. + /// Creates UserSubscriptionEntity links in bulk. If the SubscriptionEntity isn't already in the system, this is added before the userEntity is subscribed. /// /// @param requests a list of [SubscriptionDTO.SubscriptionCreateDTO] objects to create - /// @param userId the ID of the requesting user + /// @param userId the ID of the requesting userEntity /// @return a [SubscriptionDTO.BulkSubscriptionResponseDTO] DTO containing a list of successes and failures @Transactional public SubscriptionDTO.BulkSubscriptionResponseDTO addSubscriptions(List requests, Long userId) { @@ -113,7 +113,7 @@ public SubscriptionDTO.BulkSubscriptionResponseDTO addSubscriptions(List { - Optional getUserByUuid(UUID uuid); +public interface UserRepository extends JpaRepository { + Optional getUserByUuid(UUID uuid); - Optional getUserByUsername(String username); + Optional getUserByUsername(String username); Boolean existsUserByUsername(String username); Boolean existsUserByEmail(String email); - Optional findByUsername(String username); + Optional findByUsername(String username); } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserService.java b/src/main/java/org/openpodcastapi/opa/user/UserService.java index 064ab61..3c7969d 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserService.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserService.java @@ -34,19 +34,19 @@ public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) t } // 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); + UserEntity newUserEntity = mapper.toEntity(dto); + newUserEntity.setPassword(passwordEncoder.encode(dto.password())); + newUserEntity.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); + 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); + Page users = repository.findAll(pageable); log.debug("returning {} users", users.getTotalElements()); @@ -60,9 +60,9 @@ public Page getAllUsers(Pageable pageable) { /// @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)); + UserEntity userEntity = repository.getUserByUuid(uuid).orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); - repository.delete(user); + repository.delete(userEntity); 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 ff50672..bf77a85 100644 --- a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java +++ b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.user.User; +import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRoles; import org.openpodcastapi.opa.user.UserRepository; import org.springframework.beans.factory.annotation.Value; @@ -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 8053987..454ff32 100644 --- a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java +++ b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java @@ -4,7 +4,7 @@ import org.junit.jupiter.api.Test; import org.openpodcastapi.opa.security.RefreshTokenRepository; import org.openpodcastapi.opa.security.TokenService; -import org.openpodcastapi.opa.user.User; +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; @@ -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/subscriptions/UserSubscriptionEntityEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java similarity index 93% rename from src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityEntityMapperTest.java rename to src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java index a0e6eae..968c46b 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityEntityMapperTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java @@ -4,7 +4,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.openpodcastapi.opa.config.JwtAuthenticationFilter; import org.openpodcastapi.opa.subscription.*; -import org.openpodcastapi.opa.user.User; +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; @@ -17,7 +17,7 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = UserSubscriptionMapperImpl.class) -class UserSubscriptionEntityEntityMapperTest { +class UserSubscriptionEntityMapperTest { @Autowired private UserSubscriptionMapper mapper; @@ -32,7 +32,7 @@ class UserSubscriptionEntityEntityMapperTest { 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") @@ -49,7 +49,7 @@ void testToDto() { UserSubscriptionEntity userSubscriptionEntity = UserSubscriptionEntity.builder() .uuid(uuid) - .user(user) + .userEntity(userEntity) .subscription(subscriptionEntity) .isSubscribed(true) .createdAt(timestamp) diff --git a/src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java b/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java similarity index 57% rename from src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java rename to src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java index b67d9c7..8e85b97 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserMapperSpringTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java @@ -14,19 +14,19 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = UserMapperImpl.class) -class UserMapperSpringTest { +class UserEntityMapperTest { @Autowired private UserMapper mapper; @MockitoBean private UserRepository userRepository; - /// Tests that a [User] entity maps to a [UserDTO.UserResponseDTO] representation + /// Tests that a [UserEntity] entity maps to a [UserDTO.UserResponseDTO] representation @Test void testToDto() { final Instant timestamp = Instant.now(); final UUID uuid = UUID.randomUUID(); - User user = User.builder() + UserEntity userEntity = UserEntity.builder() .uuid(uuid) .username("test") .email("test@test.test") @@ -34,24 +34,24 @@ void testToDto() { .updatedAt(timestamp) .build(); - UserDTO.UserResponseDTO dto = mapper.toDto(user); + UserDTO.UserResponseDTO dto = mapper.toDto(userEntity); 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()); + 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 [User] entity + /// Tests that a [UserDTO.CreateUserDTO] maps to a [UserEntity] entity @Test void testToEntity() { UserDTO.CreateUserDTO dto = new UserDTO.CreateUserDTO("test", "testPassword", "test@test.test"); - User user = mapper.toEntity(dto); + UserEntity userEntity = mapper.toEntity(dto); - assertNotNull(user); - assertEquals(dto.email(), user.getEmail()); - assertEquals(dto.username(), user.getUsername()); - assertNull(user.getPassword()); + 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/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java index 7a924c1..ac01d99 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java @@ -82,11 +82,11 @@ void getAllUsers_shouldReturn200_andList() throws Exception { 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), @@ -100,13 +100,13 @@ void getAllUsers_shouldReturn200_andList() throws Exception { @Test @WithMockUser(roles = "USER") - // Mock the user with a "USER" role + // Mock the userEntity with a "USER" role void getAllUsers_shouldReturn403_forUserRole() throws Exception { mockMvc.perform(get("/api/v1/users") .accept(MediaType.APPLICATION_JSON) .param("page", "0") .param("size", "20")) - .andExpect(status().isForbidden()) // Expect 403 for the user role + .andExpect(status().isForbidden()) // Expect 403 for the userEntity role .andDo(document("users-list", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), From be340f0ffee3f624a36ef79ac2b611f07fd1f8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 03:08:09 +0100 Subject: [PATCH 06/12] Fix failing tests and remove redundant helper class --- .../opa/auth/JwtAccessDeniedHandler.java | 8 ++++++-- .../opa/auth/JwtAuthenticationEntryPoint.java | 8 ++++++-- .../openpodcastapi/opa/security/TokenService.java | 4 ++-- .../opa/subscription/SubscriptionService.java | 4 ++-- .../opa/subscription/UserSubscriptionEntity.java | 2 +- .../org/openpodcastapi/opa/util/JSONFormatter.java | 12 ------------ .../UserSubscriptionEntityMapperTest.java | 2 +- 7 files changed, 18 insertions(+), 22 deletions(-) delete mode 100644 src/main/java/org/openpodcastapi/opa/util/JSONFormatter.java diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java index e04d5bc..b60352f 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.java +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAccessDeniedHandler.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 org.openpodcastapi.opa.util.JSONFormatter; +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; @@ -11,7 +12,10 @@ import java.io.IOException; @Component +@RequiredArgsConstructor public class JwtAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + @Override public void handle(HttpServletRequest request, HttpServletResponse response, @@ -25,6 +29,6 @@ public void handle(HttpServletRequest request, AuthDTO.ErrorMessageDTO message = new AuthDTO.ErrorMessageDTO("Forbidden", "You do not have permission to access this resource"); - response.getWriter().write(JSONFormatter.parseDataToJSON(message)); + 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 63e7f5d..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 org.openpodcastapi.opa.util.JSONFormatter; +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -11,7 +12,10 @@ import java.io.IOException; @Component +@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 { @@ -23,6 +27,6 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A AuthDTO.ErrorMessageDTO message = new AuthDTO.ErrorMessageDTO("Access denied", "You need to log in to access this resource"); - response.getWriter().write(JSONFormatter.parseDataToJSON(message)); + response.getWriter().write(objectMapper.writeValueAsString(message)); } } diff --git a/src/main/java/org/openpodcastapi/opa/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java index cbfde43..611d66f 100644 --- a/src/main/java/org/openpodcastapi/opa/security/TokenService.java +++ b/src/main/java/org/openpodcastapi/opa/security/TokenService.java @@ -74,8 +74,8 @@ public String generateRefreshToken(UserEntity userEntity) { /// Validates the refresh token for a userEntity and updates its expiry time /// - /// @param rawToken the raw token to validate - /// @param userEntity the [UserEntity] to validate the token for + /// @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 userEntity diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java index 421d8f8..6406380 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java @@ -79,7 +79,7 @@ public Page getAllActiveSubscriptionsForUse /// If an existing entry is found for the userEntity and subscriptionEntity, the `isSubscribed` property is set to `true` /// /// @param subscriptionEntity the target subscriptionEntity - /// @param userId the ID of the target userEntity + /// @param userId the ID of the target userEntity /// @return a [SubscriptionDTO.UserSubscriptionDTO] representation of the subscriptionEntity link /// @throws EntityNotFoundException if no matching userEntity is found protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(SubscriptionEntity subscriptionEntity, Long userId) { @@ -90,7 +90,7 @@ protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(Subscripti log.debug("Creating new userEntity subscriptionEntity for userEntity {} and subscriptionEntity {}", userId, subscriptionEntity.getUuid()); UserSubscriptionEntity createdSubscription = new UserSubscriptionEntity(); createdSubscription.setIsSubscribed(true); - createdSubscription.setUserEntity(userEntity); + createdSubscription.setUser(userEntity); createdSubscription.setSubscription(subscriptionEntity); return userSubscriptionRepository.save(createdSubscription); }); diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java index bbda754..97ee197 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java @@ -25,7 +25,7 @@ public class UserSubscriptionEntity { @ManyToOne @JoinColumn(name = "user_id") - private UserEntity userEntity; + private UserEntity user; @ManyToOne @JoinColumn(name = "subscription_id") diff --git a/src/main/java/org/openpodcastapi/opa/util/JSONFormatter.java b/src/main/java/org/openpodcastapi/opa/util/JSONFormatter.java deleted file mode 100644 index f587226..0000000 --- a/src/main/java/org/openpodcastapi/opa/util/JSONFormatter.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.openpodcastapi.opa.util; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -public class JSONFormatter { - public static String parseDataToJSON(Object item) throws JsonProcessingException { - ObjectMapper objectMapper = new ObjectMapper(); - - return objectMapper.writeValueAsString(item); - } -} diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java index 968c46b..c6dc516 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java @@ -49,7 +49,7 @@ void testToDto() { UserSubscriptionEntity userSubscriptionEntity = UserSubscriptionEntity.builder() .uuid(uuid) - .userEntity(userEntity) + .user(userEntity) .subscription(subscriptionEntity) .isSubscribed(true) .createdAt(timestamp) From 39f7658db16a49cc4edc08372ae505e726ce6e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 16:18:01 +0100 Subject: [PATCH 07/12] Improve tests and output documentation --- src/docs/auth.adoc | 3 +- src/docs/subscriptions.adoc | 24 ++-- src/docs/users.adoc | 24 +--- .../opa/auth/ApiAuthController.java | 2 +- .../opa/config/JwtAuthenticationFilter.java | 6 - .../opa/config/SecurityConfig.java | 22 +++- .../openpodcastapi/opa/config/WebConfig.java | 2 +- .../{docs => controllers}/DocsController.java | 4 +- .../HomeController.java | 2 +- .../UiAuthController.java | 2 +- .../opa/{config => security}/JwtService.java | 2 +- .../SubscriptionEntityRestControllerTest.java | 124 +++++++++++------- .../UserSubscriptionEntityMapperTest.java | 4 - .../opa/user/UserRestControllerTest.java | 50 ++++++- 14 files changed, 171 insertions(+), 100 deletions(-) rename src/main/java/org/openpodcastapi/opa/{docs => controllers}/DocsController.java (80%) rename src/main/java/org/openpodcastapi/opa/{ui/controller => controllers}/HomeController.java (93%) rename src/main/java/org/openpodcastapi/opa/{ui/controller => controllers}/UiAuthController.java (98%) rename src/main/java/org/openpodcastapi/opa/{config => security}/JwtService.java (87%) diff --git a/src/docs/auth.adoc b/src/docs/auth.adoc index 788ef79..f08518b 100644 --- a/src/docs/auth.adoc +++ b/src/docs/auth.adoc @@ -15,7 +15,8 @@ The `auth` endpoint exposes operations for authenticating against the API. 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'] diff --git a/src/docs/subscriptions.adoc b/src/docs/subscriptions.adoc index 9580474..fdca226 100644 --- a/src/docs/subscriptions.adoc +++ b/src/docs/subscriptions.adoc @@ -2,7 +2,8 @@ :doctype: book :sectlinks: -The `subscriptions` endpoint exposes operations taken on subscriptions. A subscriptionEntity 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 @@ -18,9 +19,10 @@ POST /api/v1/users [[actions-subscriptions-create]] === Create subscriptions -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. +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 @@ -39,9 +41,10 @@ include::{snippets}/subscriptions-bulk-create-mixed/http-response.adoc[] [[actions-subscriptions-list]] === 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 @@ -50,13 +53,16 @@ operation::subscriptions-list-with-unsubscribed[snippets='curl-request,http-resp [[actions-subscriptionEntity-fetch]] === Fetch a single subscriptionEntity -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. +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::subscriptionEntity-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-subscriptionEntity-update]] === Unsubscribe from a feed -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. +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::subscriptionEntity-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..89691f7 100644 --- a/src/docs/users.adoc +++ b/src/docs/users.adoc @@ -2,29 +2,12 @@ :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. +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'] - [[actions-users-get]] === Get all users @@ -34,5 +17,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/auth/ApiAuthController.java b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java index 43da2fc..1207ec1 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.config.JwtService; +import org.openpodcastapi.opa.security.JwtService; import org.openpodcastapi.opa.security.TokenService; import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; diff --git a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java b/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java index 1729332..50b943e 100644 --- a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java +++ b/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java @@ -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/") || req.getRequestURI().startsWith("/docs")) { - chain.doFilter(req, res); - return; - } - String header = req.getHeader(HttpHeaders.AUTHORIZATION); SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java index 7af68b6..edf42a8 100644 --- a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java @@ -25,14 +25,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/**").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/WebConfig.java b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java index 8d0c747..69e75b8 100644 --- a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java @@ -20,7 +20,7 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { registry .addResourceHandler("/docs/**") - .addResourceLocations("classpath:/docs/"); + .addResourceLocations("classpath:/static/docs/"); } @Bean diff --git a/src/main/java/org/openpodcastapi/opa/docs/DocsController.java b/src/main/java/org/openpodcastapi/opa/controllers/DocsController.java similarity index 80% rename from src/main/java/org/openpodcastapi/opa/docs/DocsController.java rename to src/main/java/org/openpodcastapi/opa/controllers/DocsController.java index adef21f..4af4079 100644 --- a/src/main/java/org/openpodcastapi/opa/docs/DocsController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/DocsController.java @@ -1,9 +1,11 @@ -package org.openpodcastapi.opa.docs; +package org.openpodcastapi.opa.controllers; +import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller +@Log4j2 public class DocsController { @GetMapping("/docs") diff --git a/src/main/java/org/openpodcastapi/opa/ui/controller/HomeController.java b/src/main/java/org/openpodcastapi/opa/controllers/HomeController.java similarity index 93% rename from src/main/java/org/openpodcastapi/opa/ui/controller/HomeController.java rename to src/main/java/org/openpodcastapi/opa/controllers/HomeController.java index 23c9f94..986fc1f 100644 --- a/src/main/java/org/openpodcastapi/opa/ui/controller/HomeController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/HomeController.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.ui.controller; +package org.openpodcastapi.opa.controllers; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; diff --git a/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/UiAuthController.java similarity index 98% rename from src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java rename to src/main/java/org/openpodcastapi/opa/controllers/UiAuthController.java index 819be0c..52a44c7 100644 --- a/src/main/java/org/openpodcastapi/opa/ui/controller/UiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/UiAuthController.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.ui.controller; +package org.openpodcastapi.opa.controllers; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/org/openpodcastapi/opa/config/JwtService.java b/src/main/java/org/openpodcastapi/opa/security/JwtService.java similarity index 87% rename from src/main/java/org/openpodcastapi/opa/config/JwtService.java rename to src/main/java/org/openpodcastapi/opa/security/JwtService.java index 43ff4d0..6c71ca5 100644 --- a/src/main/java/org/openpodcastapi/opa/config/JwtService.java +++ b/src/main/java/org/openpodcastapi/opa/security/JwtService.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.config; +package org.openpodcastapi.opa.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java index 911c554..3d4754c 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java @@ -1,10 +1,13 @@ package org.openpodcastapi.opa.subscriptions; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.openpodcastapi.opa.service.CustomUserDetails; +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; @@ -16,7 +19,6 @@ 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; @@ -24,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; @@ -51,23 +54,49 @@ class SubscriptionEntityRestControllerTest { @Autowired private ObjectMapper objectMapper; + @Autowired + private TokenService tokenService; + + @MockitoBean + private UserRepository userRepository; + @MockitoBean 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") + @WithMockUser(username = "user") void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { - CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "alice", "alice@test.com", Set.of(UserRoles.USER)); - 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()) @@ -75,6 +104,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(), @@ -100,22 +132,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) - ); - 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", @@ -125,21 +152,23 @@ void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws @Test - @WithMockUser(username = "alice") + @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(); SubscriptionDTO.UserSubscriptionDTO sub = new SubscriptionDTO.UserSubscriptionDTO(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), true); - when(subscriptionService.getUserSubscriptionBySubscriptionUuid(subscriptionUuid, user.id())) + 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("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 subscriptionEntity to retrieve") ), @@ -154,9 +183,8 @@ void getSubscriptionByUuid_shouldReturnSubscription() throws Exception { } @Test - @WithMockUser(username = "testuser") + @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(); @@ -170,18 +198,20 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { 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 subscriptionEntity"), fieldWithPath("[].feedUrl").description("The feed URL of the subscriptionEntity to create") @@ -202,10 +232,8 @@ 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(); @@ -216,18 +244,20 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { 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 subscriptionEntity"), fieldWithPath("[].feedUrl").description("The feed URL of the subscriptionEntity to create") @@ -243,10 +273,8 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { } @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"; SubscriptionDTO.SubscriptionCreateDTO dto = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2"); @@ -256,18 +284,20 @@ void createUserSubscription_shouldReturnFailure() throws Exception { 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), @@ -278,16 +308,8 @@ void createUserSubscription_shouldReturnFailure() throws Exception { } @Test - @WithMockUser(username = "alice") + @WithMockUser(username = "user") void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception { - CustomUserDetails user = new CustomUserDetails( - 1L, - UUID.randomUUID(), - "alice", - "alice@test.com", - Set.of(UserRoles.USER) - ); - UUID subscriptionUuid = UUID.randomUUID(); boolean newStatus = false; @@ -299,13 +321,12 @@ 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()) + .header("Authorization", "Bearer " + accessToken) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.uuid").value(subscriptionUuid.toString())) @@ -314,6 +335,9 @@ void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception .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 subscriptionEntity to update") ), diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java index c6dc516..ad69b27 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java @@ -2,7 +2,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.openpodcastapi.opa.config.JwtAuthenticationFilter; import org.openpodcastapi.opa.subscription.*; import org.openpodcastapi.opa.user.UserEntity; import org.springframework.beans.factory.annotation.Autowired; @@ -24,9 +23,6 @@ class UserSubscriptionEntityMapperTest { @MockitoBean private UserSubscriptionRepository userSubscriptionRepository; - @MockitoBean - private JwtAuthenticationFilter filter; - /// Tests that a [UserSubscriptionEntity] entity maps to a [SubscriptionDTO.UserSubscriptionDTO] representation @Test void testToDto() { diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java index ac01d99..ddd9264 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java @@ -2,6 +2,7 @@ import lombok.extern.log4j.Log4j2; import org.junit.jupiter.api.Test; +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; @@ -17,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.*; @@ -40,12 +45,33 @@ class UserRestControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private TokenService tokenService; + + @MockitoBean + private UserRepository userRepository; + @MockitoBean private UserService userService; @Test - @WithMockUser(roles = {"USER", "ADMIN"}) + @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.UserResponseDTO user1 = new UserDTO.UserResponseDTO( @@ -70,6 +96,7 @@ void getAllUsers_shouldReturn200_andList() throws Exception { // 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")) @@ -77,6 +104,9 @@ 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() @@ -99,10 +129,26 @@ void getAllUsers_shouldReturn200_andList() throws Exception { } @Test - @WithMockUser(roles = "USER") + @WithMockUser(username = "user", roles = "USER") // Mock the userEntity with a "USER" role 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")) From 3da0bf5b7f07d3ae639c2c190c3d6530c9092062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 16:21:42 +0100 Subject: [PATCH 08/12] Improve documentation formatting --- src/docs/auth.adoc | 7 ++----- src/docs/index.adoc | 2 +- src/docs/subscriptions.adoc | 15 ++++++--------- src/docs/users.adoc | 5 +---- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/docs/auth.adoc b/src/docs/auth.adoc index f08518b..d1546f6 100644 --- a/src/docs/auth.adoc +++ b/src/docs/auth.adoc @@ -4,11 +4,8 @@ The `auth` endpoint exposes operations for authenticating against the API. -[[actions-auth]] -== Actions - [[actions-login]] -=== Log in +== Log in [source,httprequest] ---- @@ -21,7 +18,7 @@ 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 466f1f6..3759cd2 100644 --- a/src/docs/index.adoc +++ b/src/docs/index.adoc @@ -3,7 +3,7 @@ :icons: font :source-highlighter: highlightjs :toc: right -:toclevels: 2 +:toclevels: 1 :sectlinks: This server implements the https://openpodcastapi.org[Open Podcast API]. diff --git a/src/docs/subscriptions.adoc b/src/docs/subscriptions.adoc index fdca226..a0aba8b 100644 --- a/src/docs/subscriptions.adoc +++ b/src/docs/subscriptions.adoc @@ -8,23 +8,20 @@ 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 `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-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. @@ -39,19 +36,19 @@ 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. 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-subscriptionEntity-fetch]] -=== Fetch a single subscriptionEntity +== Fetch a single subscriptionEntity 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. @@ -59,7 +56,7 @@ Returns `404` if the user has no subscriptionEntity entry for the feed in questi operation::subscriptionEntity-get[snippets='request-headers,path-parameters,curl-request,response-fields,http-response'] [[actions-subscriptionEntity-update]] -=== Unsubscribe from a feed +== Unsubscribe from a feed Unsubscribes the authenticated user from a feed. This action updates the user subscriptionEntity record to mark the subscriptionEntity as inactive. diff --git a/src/docs/users.adoc b/src/docs/users.adoc index 89691f7..0a49d27 100644 --- a/src/docs/users.adoc +++ b/src/docs/users.adoc @@ -5,11 +5,8 @@ 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-get]] -=== Get all users +== Get all users [source,httprequest] ---- From 2a9afc78ea4151e9379a2bcbb513bd1136a19e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 16:25:31 +0100 Subject: [PATCH 09/12] Removed unused token service --- .../openpodcastapi/opa/auth/ApiAuthController.java | 7 ++----- .../openpodcastapi/opa/security/JwtService.java | 14 -------------- .../openpodcastapi/opa/security/TokenService.java | 8 ++++++++ 3 files changed, 10 insertions(+), 19 deletions(-) delete mode 100644 src/main/java/org/openpodcastapi/opa/security/JwtService.java diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java index 1207ec1..e3d362e 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java @@ -4,7 +4,6 @@ import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.openpodcastapi.opa.security.JwtService; import org.openpodcastapi.opa.security.TokenService; import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; @@ -21,8 +20,6 @@ @RequiredArgsConstructor @Log4j2 public class ApiAuthController { - - private final JwtService jwtService; private final TokenService tokenService; private final UserRepository userRepository; private final AuthenticationManager authenticationManager; @@ -45,7 +42,7 @@ public ResponseEntity login(@RequestBody @NotNull String refreshToken = tokenService.generateRefreshToken(userEntity); // Format the tokens and expiration time into a DTO - AuthDTO.LoginSuccessResponse response = new AuthDTO.LoginSuccessResponse(accessToken, refreshToken, String.valueOf(jwtService.getExpirationTime())); + AuthDTO.LoginSuccessResponse response = new AuthDTO.LoginSuccessResponse(accessToken, refreshToken, String.valueOf(tokenService.getExpirationTime())); return ResponseEntity.ok(response); } @@ -61,7 +58,7 @@ public ResponseEntity getRefreshToken(@RequestBody String newAccessToken = tokenService.generateAccessToken(userEntity); // Format the token and expiration time into a DTO - AuthDTO.RefreshTokenResponse response = new AuthDTO.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/security/JwtService.java b/src/main/java/org/openpodcastapi/opa/security/JwtService.java deleted file mode 100644 index 6c71ca5..0000000 --- a/src/main/java/org/openpodcastapi/opa/security/JwtService.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.openpodcastapi.opa.security; - -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/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java index 611d66f..2ee3fa3 100644 --- a/src/main/java/org/openpodcastapi/opa/security/TokenService.java +++ b/src/main/java/org/openpodcastapi/opa/security/TokenService.java @@ -33,11 +33,19 @@ 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 userEntity /// /// @param userEntity the [UserEntity] to generate a token for From 0b9f7ec587b0166d81f1690daf4d2e10cfae67ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 16:50:42 +0100 Subject: [PATCH 10/12] Add more tests --- .../opa/advice/GlobalExceptionHandler.java | 5 +- .../opa/subscription/SubscriptionService.java | 50 +++++++------- .../SubscriptionEntityRestControllerTest.java | 67 ++++++++++++++++++- .../opa/user/UserRestControllerTest.java | 10 ++- 4 files changed, 100 insertions(+), 32 deletions(-) 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/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java index 6406380..03dd115 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java @@ -26,31 +26,31 @@ public class SubscriptionService { /// Fetches an existing repository from the database or creates a new one if none is found /// - /// @param dto the [SubscriptionDTO.SubscriptionCreateDTO] containing the subscriptionEntity data + /// @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 subscriptionEntity with UUID {}", dto.uuid()); + log.debug("Creating new subscription with UUID {}", dto.uuid()); return subscriptionRepository.save(subscriptionMapper.toEntity(dto)); }); } - /// Fetches a single subscriptionEntity for an authenticated userEntity, if it exists + /// Fetches a single subscription for an authenticated userEntity, if it exists /// - /// @param subscriptionUuid the UUID of the subscriptionEntity - /// @param userId the database ID of the userEntity - /// @return a [SubscriptionDTO.UserSubscriptionDTO] of the userEntity subscriptionEntity + /// @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 subscriptionEntity {} for userEntity {}", subscriptionUuid, userId); + log.debug("Fetching subscription {} for userEntity {}", subscriptionUuid, userId); UserSubscriptionEntity subscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionUuid) - .orElseThrow(() -> new EntityNotFoundException("subscriptionEntity not found for userEntity")); + .orElseThrow(() -> new EntityNotFoundException("subscription not found for userEntity")); - log.debug("SubscriptionEntity {} for userEntity {} found", subscriptionUuid, userId); + log.debug("Subscription {} for userEntity {} found", subscriptionUuid, userId); return userSubscriptionMapper.toDto(subscription); } @@ -65,9 +65,9 @@ public Page getAllSubscriptionsForUser(Long .map(userSubscriptionMapper::toDto); } - /// Gets all active subscriptions for the authenticated userEntity + /// Gets all active subscriptions for the authenticated user /// - /// @param userId the database ID of the authenticated userEntity + /// @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) { @@ -75,19 +75,19 @@ public Page getAllActiveSubscriptionsForUse return userSubscriptionRepository.findAllByUserIdAndIsSubscribedTrue(userId, pageable).map(userSubscriptionMapper::toDto); } - /// Persists a new userEntity subscriptionEntity to the database - /// If an existing entry is found for the userEntity and subscriptionEntity, the `isSubscribed` property is set to `true` + /// 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 userEntity - /// @return a [SubscriptionDTO.UserSubscriptionDTO] representation of the subscriptionEntity link - /// @throws EntityNotFoundException if no matching userEntity is found + /// @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 userEntity subscriptionEntity for userEntity {} and subscriptionEntity {}", userId, subscriptionEntity.getUuid()); + log.debug("Creating new subscription for user {} and subscription {}", userId, subscriptionEntity.getUuid()); UserSubscriptionEntity createdSubscription = new UserSubscriptionEntity(); createdSubscription.setIsSubscribed(true); createdSubscription.setUser(userEntity); @@ -99,10 +99,10 @@ protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(Subscripti return userSubscriptionMapper.toDto(userSubscriptionRepository.save(newSubscription)); } - /// Creates UserSubscriptionEntity links in bulk. If the SubscriptionEntity isn't already in the system, this is added before the userEntity is subscribed. + /// 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 userEntity + /// @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) { @@ -113,7 +113,7 @@ public SubscriptionDTO.BulkSubscriptionResponseDTO addSubscriptions(List new EntityNotFoundException("no subscriptionEntity found")); + .orElseThrow(() -> new EntityNotFoundException("no subscription found")); subscription.setIsSubscribed(false); return userSubscriptionMapper.toDto(userSubscriptionRepository.save(subscription)); diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java index 3d4754c..b135c72 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java @@ -1,6 +1,7 @@ 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.security.TokenService; @@ -17,7 +18,6 @@ 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.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; @@ -85,6 +85,14 @@ void setup() { accessToken = tokenService.generateAccessToken(mockUser); } + @Test + void getAllSubscriptionsForAnonymous_shouldReturn401() throws Exception { + mockMvc.perform(get("/api/v1/subscriptions") + .param("page", "0") + .param("size", "20")) + .andExpect(status().isUnauthorized()); + } + @Test @WithMockUser(username = "user") void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { @@ -150,6 +158,24 @@ 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 = "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") @@ -182,6 +208,22 @@ void getSubscriptionByUuid_shouldReturnSubscription() throws Exception { )); } + @Test + 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 { @@ -307,9 +349,28 @@ void createUserSubscription_shouldReturnFailure() throws Exception { ))); } + @Test + 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 updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception { + void unsubscribe_shouldReturnUpdatedSubscription() throws Exception { UUID subscriptionUuid = UUID.randomUUID(); boolean newStatus = false; @@ -325,7 +386,7 @@ void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception .thenReturn(updatedSubscription); // Act & Assert - mockMvc.perform(RestDocumentationRequestBuilders.post("/api/v1/subscriptions/{uuid}/unsubscribe", subscriptionUuid) + mockMvc.perform(post("/api/v1/subscriptions/{uuid}/unsubscribe", subscriptionUuid) .header("Authorization", "Bearer " + accessToken) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java index ddd9264..218c612 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java @@ -54,6 +54,13 @@ class UserRestControllerTest { @MockitoBean private UserService userService; + @Test + 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 { @@ -130,7 +137,6 @@ void getAllUsers_shouldReturn200_andList() throws Exception { @Test @WithMockUser(username = "user", roles = "USER") - // Mock the userEntity with a "USER" role void getAllUsers_shouldReturn403_forUserRole() throws Exception { UserEntity mockUser = UserEntity .builder() @@ -152,7 +158,7 @@ void getAllUsers_shouldReturn403_forUserRole() throws Exception { .accept(MediaType.APPLICATION_JSON) .param("page", "0") .param("size", "20")) - .andExpect(status().isForbidden()) // Expect 403 for the userEntity role + .andExpect(status().isForbidden()) .andDo(document("users-list", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), From bde0ec09bc3781acd99569afd57da96c33ff5883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 17:05:41 +0100 Subject: [PATCH 11/12] Clean up code comments --- .../org/openpodcastapi/opa/auth/AuthDTO.java | 4 ++-- .../api/AuthController.java} | 7 ++++-- .../controllers/{ => web}/DocsController.java | 4 +++- .../controllers/{ => web}/HomeController.java | 4 +++- .../WebAuthController.java} | 4 ++-- .../opa/security/TokenService.java | 10 ++++----- .../opa/subscription/SubscriptionDTO.java | 16 +++++++------- .../SubscriptionRestController.java | 22 +++++++++---------- .../org/openpodcastapi/opa/user/UserDTO.java | 2 +- .../opa/user/UserRestController.java | 12 ++++++++++ .../openpodcastapi/opa/user/UserService.java | 4 ++-- .../opa/util/AdminUserInitializer.java | 2 +- 12 files changed, 55 insertions(+), 36 deletions(-) rename src/main/java/org/openpodcastapi/opa/{auth/ApiAuthController.java => controllers/api/AuthController.java} (94%) rename src/main/java/org/openpodcastapi/opa/controllers/{ => web}/DocsController.java (76%) rename src/main/java/org/openpodcastapi/opa/controllers/{ => web}/HomeController.java (84%) rename src/main/java/org/openpodcastapi/opa/controllers/{UiAuthController.java => web/WebAuthController.java} (96%) diff --git a/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java b/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java index 0c8bf6e..2d70c1a 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java +++ b/src/main/java/org/openpodcastapi/opa/auth/AuthDTO.java @@ -5,7 +5,7 @@ /// All data transfer objects for auth methods public class AuthDTO { - /// A DTO representing an API login request + /// 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) 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 94% rename from src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java rename to src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java index e3d362e..e59eba8 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java @@ -1,9 +1,10 @@ -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.auth.AuthDTO; import org.openpodcastapi.opa.security.TokenService; import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; @@ -19,11 +20,12 @@ @RestController @RequiredArgsConstructor @Log4j2 -public class ApiAuthController { +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 AuthDTO.LoginRequest loginRequest) { // Set the authentication using the provided details @@ -47,6 +49,7 @@ public ResponseEntity login(@RequestBody @NotNull return ResponseEntity.ok(response); } + // === Refresh token endpoint === @PostMapping("/api/auth/refresh") public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) { UserEntity targetUserEntity = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); diff --git a/src/main/java/org/openpodcastapi/opa/controllers/DocsController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java similarity index 76% rename from src/main/java/org/openpodcastapi/opa/controllers/DocsController.java rename to src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java index 4af4079..c4360b0 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/DocsController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.controllers; +package org.openpodcastapi.opa.controllers.web; import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Controller; @@ -8,11 +8,13 @@ @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/controllers/HomeController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java similarity index 84% rename from src/main/java/org/openpodcastapi/opa/controllers/HomeController.java rename to src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java index 986fc1f..ad4f83b 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/HomeController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.controllers; +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/controllers/UiAuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java similarity index 96% rename from src/main/java/org/openpodcastapi/opa/controllers/UiAuthController.java rename to src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java index 52a44c7..4aeccef 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/UiAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java @@ -1,4 +1,4 @@ -package org.openpodcastapi.opa.controllers; +package org.openpodcastapi.opa.controllers.web; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -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; diff --git a/src/main/java/org/openpodcastapi/opa/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java index 2ee3fa3..9d20198 100644 --- a/src/main/java/org/openpodcastapi/opa/security/TokenService.java +++ b/src/main/java/org/openpodcastapi/opa/security/TokenService.java @@ -46,7 +46,7 @@ public long getExpirationTime() { return Long.parseLong(jwtExpiration); } - /// Generates an access token for a given userEntity + /// Generates an access token for a given user /// /// @param userEntity the [UserEntity] to generate a token for /// @return the generated token @@ -61,7 +61,7 @@ public String generateAccessToken(UserEntity userEntity) { .compact(); } - /// Generates a refresh token for a given userEntity + /// Generates a refresh token for a given user /// /// @param userEntity the [UserEntity] to generate a refresh token for /// @return the generated refresh token @@ -80,13 +80,13 @@ public String generateRefreshToken(UserEntity userEntity) { return raw; } - /// Validates the refresh token for a userEntity and updates its expiry time + /// Validates the refresh token for a user and updates its expiry time /// /// @param rawToken the raw token to validate /// @param userEntity the [UserEntity] to validate the token for /// @return the validated [UserEntity] public UserEntity validateRefreshToken(String rawToken, UserEntity userEntity) { - // Only fetch refresh tokens for the requesting userEntity + // Only fetch refresh tokens for the requesting 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()) && @@ -95,7 +95,7 @@ public UserEntity validateRefreshToken(String rawToken, UserEntity userEntity) { token.setExpiresAt(Instant.now().plusSeconds(refreshTokenDays * 24 * 3600)); RefreshTokenEntity updatedToken = repository.save(token); - // Return the userEntity to confirm the token is valid + // Return the user to confirm the token is valid return updatedToken.getUser(); } } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java index b3d4f38..324c3bd 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java @@ -10,7 +10,7 @@ import java.util.List; public class SubscriptionDTO { - /// A DTO representing a new subscriptionEntity + /// A DTO representing a new subscription /// /// @param feedUrl the URL of the feed /// @param uuid the UUID of the feed calculated by the client @@ -20,12 +20,12 @@ public record SubscriptionCreateDTO( ) { } - /// A DTO representing a user's subscriptionEntity to a given feed + /// 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 subscriptionEntity link was created - /// @param updatedAt the date at which the subscriptionEntity link was last updated + /// @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, @@ -36,7 +36,7 @@ public record UserSubscriptionDTO( ) { } - /// A DTO representing a bulk subscriptionEntity creation + /// A DTO representing a bulk subscription creation /// /// @param success a list of creation successes /// @param failure a list of creation failures @@ -46,10 +46,10 @@ public record BulkSubscriptionResponseDTO( ) { } - /// A DTO representing a failed subscriptionEntity creation + /// A DTO representing a failed subscription creation /// - /// @param uuid the UUID of the failed subscriptionEntity - /// @param feedUrl the feed URL of the failed subscriptionEntity + /// @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, diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java index 3fd8f32..930ca74 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java @@ -22,12 +22,12 @@ public class SubscriptionRestController { private final SubscriptionService service; - /// Returns all subscriptions for a given userEntity + /// Returns all subscriptions for a given user /// - /// @param user the [CustomUserDetails] of the authenticated userEntity + /// @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')") @@ -46,10 +46,10 @@ public ResponseEntity getAllSubscriptionsFo return new ResponseEntity<>(SubscriptionDTO.SubscriptionPageDTO.fromPage(dto), HttpStatus.OK); } - /// Returns a single subscriptionEntity entry by UUID + /// Returns a single subscription entry by UUID /// /// @param uuid the UUID value to query for - /// @return the subscriptionEntity 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}") @@ -60,17 +60,17 @@ public ResponseEntity getSubscriptionByUuid // If the value is invalid, the GlobalExceptionHandler will throw a 400. UUID uuidValue = UUID.fromString(uuid); - // Fetch the subscriptionEntity, throw an EntityNotFoundException if this fails + // Fetch the subscription, throw an EntityNotFoundException if this fails SubscriptionDTO.UserSubscriptionDTO dto = service.getUserSubscriptionBySubscriptionUuid(uuidValue, user.id()); // Return the mapped subscriptionEntity entry return new ResponseEntity<>(dto, HttpStatus.OK); } - /// Updates the subscriptionEntity status of a subscriptionEntity for a given userEntity + /// Updates the subscription status of a subscription for a given user /// - /// @param uuid the UUID of the subscriptionEntity to update - /// @return the updated subscriptionEntity entity + /// @param uuid the UUID of the subscription to update + /// @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") @@ -86,10 +86,10 @@ public ResponseEntity unsubscribeUserFromFe return new ResponseEntity<>(dto, HttpStatus.OK); } - /// Bulk creates UserSubscriptions for a userEntity. Creates new SubscriptionEntity 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 [SubscriptionDTO.SubscriptionCreateDTO] objects - /// @return a [SubscriptionDTO.BulkSubscriptionResponseDTO] object + /// @return a [ResponseEntity] containing a [SubscriptionDTO.BulkSubscriptionResponseDTO] object @PostMapping @PreAuthorize("hasRole('USER')") public ResponseEntity createUserSubscriptions(@RequestBody List request, @AuthenticationPrincipal CustomUserDetails user) { diff --git a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java index 14f9b08..63272f4 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java @@ -10,7 +10,7 @@ import java.util.UUID; public class UserDTO { - /// A DTO representing a user response over the API + /// A DTO representing a user response over the api /// /// @param uuid the UUID of the user /// @param username the username of the user diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java index f324b11..4325b07 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java @@ -17,6 +17,10 @@ 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')") @@ -26,6 +30,10 @@ public ResponseEntity getAllUsers(Pageable pageable) { 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 UserDTO.CreateUserDTO request) { @@ -36,6 +44,10 @@ public ResponseEntity createUser(@RequestBody @Validate 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/UserService.java b/src/main/java/org/openpodcastapi/opa/user/UserService.java index 3c7969d..c7f1059 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserService.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserService.java @@ -24,7 +24,7 @@ public class UserService { /// Persists a user to the database /// /// @param dto the [UserDTO.CreateUserDTO] for the user - /// @return the formatted DTO representation of 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 { @@ -55,7 +55,7 @@ public Page getAllUsers(Pageable pageable) { /// Deletes a user from the database /// - /// @param uuid the UUID of the user to delete + /// @param uuid the [UUID] of the user to delete /// @return a success message /// @throws EntityNotFoundException if no matching record is found @Transactional diff --git a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java index bf77a85..698443b 100644 --- a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java +++ b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java @@ -3,8 +3,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.user.UserEntity; -import org.openpodcastapi.opa.user.UserRoles; 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; From 0d7caf19ebb4ecf726cfed4f6ff7c9ce75390ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Sun, 23 Nov 2025 17:06:33 +0100 Subject: [PATCH 12/12] Move middleware to auth package --- .../opa/{config => auth}/JwtAuthenticationFilter.java | 2 +- src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) rename src/main/java/org/openpodcastapi/opa/{config => auth}/JwtAuthenticationFilter.java (99%) diff --git a/src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationFilter.java similarity index 99% rename from src/main/java/org/openpodcastapi/opa/config/JwtAuthenticationFilter.java rename to src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationFilter.java index 50b943e..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; diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java index edf42a8..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;