Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/docs/subscriptions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubscriptionPageDto> getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable) {
Page<UserSubscriptionDto> dto = service.getAllSubscriptionsForUser(user.id(), pageable);
public ResponseEntity<SubscriptionPageDto> getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) {
Page<UserSubscriptionDto> 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);
}
Expand All @@ -43,7 +52,8 @@ public ResponseEntity<SubscriptionPageDto> 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<UserSubscriptionDto> getSubscriptionByUuid(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) throws EntityNotFoundException {
Expand All @@ -58,6 +68,24 @@ public ResponseEntity<UserSubscriptionDto> 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<UserSubscriptionDto> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface UserSubscriptionRepository extends JpaRepository<UserSubscripti
Optional<UserSubscription> findByUserIdAndSubscriptionUuid(Long userId, UUID subscriptionUuid);

Page<UserSubscription> findAllByUserId(Long userId, Pageable pageable);

Page<UserSubscription> findAllByUserIdAndIsSubscribedTrue(Long userId, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ public Page<UserSubscriptionDto> 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<UserSubscriptionDto> 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`
///
Expand Down Expand Up @@ -113,4 +123,18 @@ public BulkSubscriptionResponse addSubscriptions(List<SubscriptionCreateDto> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<UserSubscriptionDto> page = new PageImpl<>(List.of(sub1, sub2));

Page<UserSubscriptionDto> 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")
Expand All @@ -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),
Expand All @@ -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<UserSubscriptionDto> 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")));
Expand Down Expand Up @@ -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)
)
));
}
}
Loading