Skip to content

Commit 78e7bac

Browse files
authored
Add unsubscribe endpoint (#3)
* Add unsubscribe endpoint, control for including unsubscribed in subscription list
1 parent fa9c02c commit 78e7bac

File tree

5 files changed

+151
-11
lines changed

5 files changed

+151
-11
lines changed

src/docs/subscriptions.adoc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,20 @@ When a user fetches a list of subscriptions, their own subscriptions are returne
4343

4444
operation::subscriptions-list[snippets='query-parameters,curl-request,response-fields,http-response']
4545

46+
==== Include unsubscribed
47+
48+
operation::subscriptions-list-with-unsubscribed[snippets='curl-request,http-response']
49+
4650
[[actions-subscription-fetch]]
4751
=== Fetch a single subscription
4852

4953
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.
5054

5155
operation::subscription-get[snippets='path-parameters,curl-request,response-fields,http-response']
56+
57+
[[actions-subscription-update]]
58+
=== Unsubscribe from a feed
59+
60+
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.
61+
62+
operation::subscription-unsubscribe[snippets='path-parameters,curl-request,response-fields,http-response']

src/main/java/org/openpodcastapi/opa/subscription/controller/SubscriptionRestController.java

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,22 @@ public class SubscriptionRestController {
2828

2929
/// Returns all subscriptions for a given user
3030
///
31-
/// @param user the [CustomUserDetails] of the authenticated user
32-
/// @param pageable the [Pageable] pagination object
31+
/// @param user the [CustomUserDetails] of the authenticated user
32+
/// @param pageable the [Pageable] pagination object
33+
/// @param includeUnsubscribed whether to include unsubscribed feeds in the response
3334
/// @return a paginated list of subscriptions
3435
@GetMapping
3536
@ResponseStatus(HttpStatus.OK)
36-
public ResponseEntity<SubscriptionPageDto> getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable) {
37-
Page<UserSubscriptionDto> dto = service.getAllSubscriptionsForUser(user.id(), pageable);
37+
public ResponseEntity<SubscriptionPageDto> getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) {
38+
Page<UserSubscriptionDto> dto;
39+
40+
if (includeUnsubscribed) {
41+
dto = service.getAllSubscriptionsForUser(user.id(), pageable);
42+
} else {
43+
dto = service.getAllActiveSubscriptionsForUser(user.id(), pageable);
44+
}
45+
46+
log.debug("{}", dto);
3847

3948
return new ResponseEntity<>(SubscriptionPageDto.fromPage(dto), HttpStatus.OK);
4049
}
@@ -43,7 +52,8 @@ public ResponseEntity<SubscriptionPageDto> getAllSubscriptionsForUser(@Authentic
4352
///
4453
/// @param uuid the UUID value to query for
4554
/// @return the subscription entity
46-
/// @throws EntityNotFoundException if no entry is found
55+
/// @throws EntityNotFoundException if no entry is found
56+
/// @throws IllegalArgumentException if the UUID is improperly formatted
4757
@GetMapping("/{uuid}")
4858
@ResponseStatus(HttpStatus.OK)
4959
public ResponseEntity<UserSubscriptionDto> getSubscriptionByUuid(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) throws EntityNotFoundException {
@@ -58,6 +68,24 @@ public ResponseEntity<UserSubscriptionDto> getSubscriptionByUuid(@PathVariable S
5868
return new ResponseEntity<>(dto, HttpStatus.OK);
5969
}
6070

71+
/// Updates the subscription status of a subscription for a given user
72+
///
73+
/// @param uuid the UUID of the subscription to update
74+
/// @return the updated subscription entity
75+
/// @throws EntityNotFoundException if no entry is found
76+
/// @throws IllegalArgumentException if the UUID is improperly formatted
77+
@PostMapping("/{uuid}/unsubscribe")
78+
@ResponseStatus(HttpStatus.OK)
79+
public ResponseEntity<UserSubscriptionDto> unsubscribeUserFromFeed(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) {
80+
// Attempt to validate the UUID value from the provided string
81+
// If the value is invalid, the GlobalExceptionHandler will throw a 400.
82+
UUID uuidValue = UUID.fromString(uuid);
83+
84+
UserSubscriptionDto dto = service.unsubscribeUserFromFeed(uuidValue, user.id());
85+
86+
return new ResponseEntity<>(dto, HttpStatus.OK);
87+
}
88+
6189
/// Bulk creates UserSubscriptions for a user. Creates new Subscription objects if not already present
6290
///
6391
/// @param request a list of [SubscriptionCreateDto] objects

src/main/java/org/openpodcastapi/opa/subscription/repository/UserSubscriptionRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ public interface UserSubscriptionRepository extends JpaRepository<UserSubscripti
1414
Optional<UserSubscription> findByUserIdAndSubscriptionUuid(Long userId, UUID subscriptionUuid);
1515

1616
Page<UserSubscription> findAllByUserId(Long userId, Pageable pageable);
17+
18+
Page<UserSubscription> findAllByUserIdAndIsSubscribedTrue(Long userId, Pageable pageable);
1719
}

src/main/java/org/openpodcastapi/opa/subscription/service/UserSubscriptionService.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ public Page<UserSubscriptionDto> getAllSubscriptionsForUser(Long userId, Pageabl
5858
.map(userSubscriptionMapper::toDto);
5959
}
6060

61+
/// Gets all active subscriptions for the authenticated user
62+
///
63+
/// @param userId the database ID of the authenticated user
64+
/// @return a paginated set of [UserSubscriptionDto] objects
65+
@Transactional(readOnly = true)
66+
public Page<UserSubscriptionDto> getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) {
67+
log.debug("Fetching all active subscriptions for {}", userId);
68+
return userSubscriptionRepository.findAllByUserIdAndIsSubscribedTrue(userId, pageable).map(userSubscriptionMapper::toDto);
69+
}
70+
6171
/// Persists a new user subscription to the database
6272
/// If an existing entry is found for the user and subscription, the `isSubscribed` property is set to `true`
6373
///
@@ -113,4 +123,18 @@ public BulkSubscriptionResponse addSubscriptions(List<SubscriptionCreateDto> req
113123
// Return the entire DTO of successes and failures
114124
return new BulkSubscriptionResponse(successes, failures);
115125
}
126+
127+
/// Updates the status of a subscription for a given user
128+
///
129+
/// @param feedUUID the UUID of the subscription feed
130+
/// @param userId the ID of the user
131+
/// @return a [UserSubscriptionDto] containing the updated object
132+
@Transactional
133+
public UserSubscriptionDto unsubscribeUserFromFeed(UUID feedUUID, Long userId) {
134+
UserSubscription subscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, feedUUID)
135+
.orElseThrow(() -> new EntityNotFoundException("no subscription found"));
136+
137+
subscription.setIsSubscribed(false);
138+
return userSubscriptionMapper.toDto(userSubscriptionRepository.save(subscription));
139+
}
116140
}

src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
1616
import org.springframework.data.domain.Page;
1717
import org.springframework.data.domain.PageImpl;
18-
import org.springframework.data.domain.PageRequest;
18+
import org.springframework.data.domain.Pageable;
1919
import org.springframework.http.MediaType;
2020
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;
2121
import org.springframework.restdocs.payload.JsonFieldType;
@@ -28,8 +28,7 @@
2828
import java.util.List;
2929
import java.util.UUID;
3030

31-
import static org.mockito.ArgumentMatchers.anyList;
32-
import static org.mockito.ArgumentMatchers.eq;
31+
import static org.mockito.ArgumentMatchers.*;
3332
import static org.mockito.Mockito.when;
3433
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
3534
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
@@ -60,9 +59,9 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception {
6059

6160
UserSubscriptionDto sub1 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true);
6261
UserSubscriptionDto sub2 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), true);
62+
Page<UserSubscriptionDto> page = new PageImpl<>(List.of(sub1, sub2));
6363

64-
Page<UserSubscriptionDto> page = new PageImpl<>(List.of(sub1, sub2), PageRequest.of(0, 2), 2);
65-
when(subscriptionService.getAllSubscriptionsForUser(user.id(), PageRequest.of(0, 20)))
64+
when(subscriptionService.getAllActiveSubscriptionsForUser(eq(user.id()), any(Pageable.class)))
6665
.thenReturn(page);
6766

6867
mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/subscriptions")
@@ -76,7 +75,10 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception {
7675
preprocessResponse(prettyPrint()),
7776
queryParameters(
7877
parameterWithName("page").description("The page number to fetch").optional(),
79-
parameterWithName("size").description("The number of results to include on each page").optional()
78+
parameterWithName("size").description("The number of results to include on each page").optional(),
79+
parameterWithName("includeUnsubscribed")
80+
.optional()
81+
.description("If true, includes unsubscribed feeds in the results. Defaults to false.")
8082
),
8183
responseFields(
8284
fieldWithPath("subscriptions[].uuid").description("The UUID of the subscription").type(JsonFieldType.STRING),
@@ -95,6 +97,30 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception {
9597
));
9698
}
9799

100+
@Test
101+
void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws Exception {
102+
CustomUserDetails user = new CustomUserDetails(
103+
1L, UUID.randomUUID(), "alice", "alice@test.com",
104+
List.of(new SimpleGrantedAuthority("ROLE_USER"))
105+
);
106+
107+
UserSubscriptionDto sub1 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true);
108+
UserSubscriptionDto sub2 = new UserSubscriptionDto(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), false);
109+
Page<UserSubscriptionDto> page = new PageImpl<>(List.of(sub1, sub2));
110+
111+
when(subscriptionService.getAllSubscriptionsForUser(eq(user.id()), any(Pageable.class)))
112+
.thenReturn(page);
113+
114+
mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/subscriptions")
115+
.with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities())))
116+
.param("includeUnsubscribed", "true"))
117+
.andExpect(status().isOk())
118+
.andDo(document("subscriptions-list-with-unsubscribed",
119+
preprocessRequest(prettyPrint()),
120+
preprocessResponse(prettyPrint())));
121+
}
122+
123+
98124
@Test
99125
void getSubscriptionByUuid_shouldReturnSubscription() throws Exception {
100126
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 {
243269
fieldWithPath("failure[].message").description("The error message").type(JsonFieldType.STRING)
244270
)));
245271
}
272+
273+
@Test
274+
void updateSubscriptionStatus_shouldReturnUpdatedSubscription() throws Exception {
275+
CustomUserDetails user = new CustomUserDetails(
276+
1L,
277+
UUID.randomUUID(),
278+
"alice",
279+
"alice@test.com",
280+
List.of(new SimpleGrantedAuthority("ROLE_USER"))
281+
);
282+
283+
UUID subscriptionUuid = UUID.randomUUID();
284+
boolean newStatus = false;
285+
286+
UserSubscriptionDto updatedSubscription = new UserSubscriptionDto(
287+
subscriptionUuid,
288+
"test.com/feed1",
289+
Instant.now(),
290+
Instant.now(),
291+
newStatus
292+
);
293+
294+
when(subscriptionService.unsubscribeUserFromFeed(subscriptionUuid, user.id()))
295+
.thenReturn(updatedSubscription);
296+
297+
// Act & Assert
298+
mockMvc.perform(RestDocumentationRequestBuilders.post("/api/v1/subscriptions/{uuid}/unsubscribe", subscriptionUuid)
299+
.with(authentication(new UsernamePasswordAuthenticationToken(user, "password", user.getAuthorities())))
300+
.with(csrf().asHeader())
301+
.accept(MediaType.APPLICATION_JSON))
302+
.andExpect(status().isOk())
303+
.andExpect(jsonPath("$.uuid").value(subscriptionUuid.toString()))
304+
.andExpect(jsonPath("$.feedUrl").value("test.com/feed1"))
305+
.andExpect(jsonPath("$.isSubscribed").value(false))
306+
.andDo(document("subscription-unsubscribe",
307+
preprocessRequest(prettyPrint()),
308+
preprocessResponse(prettyPrint()),
309+
pathParameters(
310+
parameterWithName("uuid").description("UUID of the subscription to update")
311+
),
312+
responseFields(
313+
fieldWithPath("uuid").description("The UUID of the subscription").type(JsonFieldType.STRING),
314+
fieldWithPath("feedUrl").description("The feed URL of the subscription").type(JsonFieldType.STRING),
315+
fieldWithPath("createdAt").description("When the subscription was created").type(JsonFieldType.STRING),
316+
fieldWithPath("updatedAt").description("When the subscription was last updated").type(JsonFieldType.STRING),
317+
fieldWithPath("isSubscribed").description("The updated subscription status").type(JsonFieldType.BOOLEAN)
318+
)
319+
));
320+
}
246321
}

0 commit comments

Comments
 (0)