diff --git a/src/docs/subscriptions.adoc b/src/docs/subscriptions.adoc index 2fb0e6a..1a9fcf8 100644 --- a/src/docs/subscriptions.adoc +++ b/src/docs/subscriptions.adoc @@ -43,9 +43,20 @@ When a user fetches a list of subscriptions, their own subscriptions are returne operation::subscriptions-list[snippets='query-parameters,curl-request,response-fields,http-response'] +==== Include unsubscribed + +operation::subscriptions-list-with-unsubscribed[snippets='curl-request,http-response'] + [[actions-subscription-fetch]] === Fetch a single subscription 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. operation::subscription-get[snippets='path-parameters,curl-request,response-fields,http-response'] + +[[actions-subscription-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. + +operation::subscription-unsubscribe[snippets='path-parameters,curl-request,response-fields,http-response'] diff --git a/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java index 373e4eb..23ed62c 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java @@ -28,13 +28,22 @@ public class SubscriptionRestController { /// Returns all subscriptions for a given user /// - /// @param user the [CustomUserDetails] of the authenticated user - /// @param pageable the [Pageable] pagination object + /// @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 @GetMapping @ResponseStatus(HttpStatus.OK) - public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable) { - Page dto = service.getAllSubscriptionsForUser(user.id(), pageable); + public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) { + Page dto; + + if (includeUnsubscribed) { + dto = service.getAllSubscriptionsForUser(user.id(), pageable); + } else { + dto = service.getAllActiveSubscriptionsForUser(user.id(), pageable); + } + + log.debug("{}", dto); return new ResponseEntity<>(SubscriptionPageDto.fromPage(dto), HttpStatus.OK); } @@ -43,7 +52,8 @@ public ResponseEntity getAllSubscriptionsForUser(@Authentic /// /// @param uuid the UUID value to query for /// @return the subscription entity - /// @throws EntityNotFoundException if no entry is found + /// @throws EntityNotFoundException if no entry is found + /// @throws IllegalArgumentException if the UUID is improperly formatted @GetMapping("/{uuid}") @ResponseStatus(HttpStatus.OK) public ResponseEntity getSubscriptionByUuid(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) throws EntityNotFoundException { @@ -58,6 +68,24 @@ public ResponseEntity getSubscriptionByUuid(@PathVariable S return new ResponseEntity<>(dto, HttpStatus.OK); } + /// Updates the subscription status of a subscription for a given user + /// + /// @param uuid the UUID of the subscription to update + /// @return the updated subscription entity + /// @throws EntityNotFoundException if no entry is found + /// @throws IllegalArgumentException if the UUID is improperly formatted + @PostMapping("/{uuid}/unsubscribe") + @ResponseStatus(HttpStatus.OK) + 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()); + + return new ResponseEntity<>(dto, HttpStatus.OK); + } + /// Bulk creates UserSubscriptions for a user. Creates new Subscription objects if not already present /// /// @param request a list of [SubscriptionCreateDto] objects diff --git a/src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java index 894bbaa..1f4b6d3 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java @@ -14,4 +14,6 @@ public interface UserSubscriptionRepository extends JpaRepository 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/UserSubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/service/UserSubscriptionService.java index 5eba1fe..f50359a 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/service/UserSubscriptionService.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/service/UserSubscriptionService.java @@ -58,6 +58,16 @@ public Page getAllSubscriptionsForUser(Long userId, Pageabl .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` /// @@ -113,4 +123,18 @@ public BulkSubscriptionResponse addSubscriptions(List req // 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/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java index ddb1c2a..c92fddc 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java @@ -15,7 +15,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; import org.springframework.restdocs.payload.JsonFieldType; @@ -28,8 +28,7 @@ import java.util.List; import java.util.UUID; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; @@ -60,9 +59,9 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { 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)); - Page page = new PageImpl<>(List.of(sub1, sub2), PageRequest.of(0, 2), 2); - when(subscriptionService.getAllSubscriptionsForUser(user.id(), PageRequest.of(0, 20))) + when(subscriptionService.getAllActiveSubscriptionsForUser(eq(user.id()), any(Pageable.class))) .thenReturn(page); mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/subscriptions") @@ -76,7 +75,10 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { preprocessResponse(prettyPrint()), queryParameters( parameterWithName("page").description("The page number to fetch").optional(), - parameterWithName("size").description("The number of results to include on each page").optional() + parameterWithName("size").description("The number of results to include on each page").optional(), + parameterWithName("includeUnsubscribed") + .optional() + .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), @@ -95,6 +97,30 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { )); } + @Test + void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws Exception { + CustomUserDetails user = new CustomUserDetails( + 1L, UUID.randomUUID(), "alice", "alice@test.com", + List.of(new SimpleGrantedAuthority("ROLE_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)); + + when(subscriptionService.getAllSubscriptionsForUser(eq(user.id()), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/subscriptions") + .with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities()))) + .param("includeUnsubscribed", "true")) + .andExpect(status().isOk()) + .andDo(document("subscriptions-list-with-unsubscribed", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))); + } + + @Test void getSubscriptionByUuid_shouldReturnSubscription() throws Exception { CustomUserDetails user = new CustomUserDetails(1L, UUID.randomUUID(), "alice", "alice@test.com", List.of(new SimpleGrantedAuthority("ROLE_USER"))); @@ -243,4 +269,53 @@ void createUserSubscription_shouldReturnFailure() throws Exception { fieldWithPath("failure[].message").description("The error message").type(JsonFieldType.STRING) ))); } + + @Test + void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception { + CustomUserDetails user = new CustomUserDetails( + 1L, + UUID.randomUUID(), + "alice", + "alice@test.com", + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + UUID subscriptionUuid = UUID.randomUUID(); + boolean newStatus = false; + + UserSubscriptionDto updatedSubscription = new UserSubscriptionDto( + subscriptionUuid, + "test.com/feed1", + Instant.now(), + Instant.now(), + newStatus + ); + + when(subscriptionService.unsubscribeUserFromFeed(subscriptionUuid, user.id())) + .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()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.uuid").value(subscriptionUuid.toString())) + .andExpect(jsonPath("$.feedUrl").value("test.com/feed1")) + .andExpect(jsonPath("$.isSubscribed").value(false)) + .andDo(document("subscription-unsubscribe", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("uuid").description("UUID of the subscription 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) + ) + )); + } }