mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
feat: send notification for new entries with Gotify, ntfy or Pushover, configurable per feed.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
package com.commafeed.backend.feed;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import com.codahale.metrics.MetricRegistry;
|
||||
import com.commafeed.backend.dao.FeedSubscriptionDAO;
|
||||
import com.commafeed.backend.dao.UnitOfWork;
|
||||
import com.commafeed.backend.dao.UserSettingsDAO;
|
||||
import com.commafeed.backend.feed.parser.FeedParserResult.Content;
|
||||
import com.commafeed.backend.feed.parser.FeedParserResult.Entry;
|
||||
import com.commafeed.backend.model.Feed;
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.FeedSubscription;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserSettings;
|
||||
import com.commafeed.backend.service.FeedEntryService;
|
||||
import com.commafeed.backend.service.FeedService;
|
||||
import com.commafeed.backend.service.NotificationService;
|
||||
import com.commafeed.frontend.ws.WebSocketSessions;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FeedRefreshUpdaterTest {
|
||||
|
||||
@Mock
|
||||
private UnitOfWork unitOfWork;
|
||||
|
||||
@Mock
|
||||
private FeedService feedService;
|
||||
|
||||
@Mock
|
||||
private FeedEntryService feedEntryService;
|
||||
|
||||
@Mock
|
||||
private FeedSubscriptionDAO feedSubscriptionDAO;
|
||||
|
||||
@Mock
|
||||
private UserSettingsDAO userSettingsDAO;
|
||||
|
||||
@Mock
|
||||
private WebSocketSessions webSocketSessions;
|
||||
|
||||
@Mock
|
||||
private NotificationService notificationService;
|
||||
|
||||
private FeedRefreshUpdater updater;
|
||||
|
||||
private Feed feed;
|
||||
private User user;
|
||||
private FeedSubscription subscription;
|
||||
private Entry entry;
|
||||
private FeedEntry feedEntry;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
MetricRegistry metrics = new MetricRegistry();
|
||||
updater = new FeedRefreshUpdater(unitOfWork, feedService, feedEntryService, metrics, feedSubscriptionDAO, userSettingsDAO,
|
||||
webSocketSessions, notificationService);
|
||||
|
||||
// UnitOfWork passthrough: execute callables and runnables directly
|
||||
Mockito.when(unitOfWork.call(Mockito.any())).thenAnswer(inv -> inv.getArgument(0, Callable.class).call());
|
||||
Mockito.doAnswer(inv -> {
|
||||
inv.getArgument(0, Runnable.class).run();
|
||||
return null;
|
||||
}).when(unitOfWork).run(Mockito.any());
|
||||
|
||||
user = new User();
|
||||
user.setId(1L);
|
||||
|
||||
feed = new Feed();
|
||||
feed.setId(1L);
|
||||
feed.setUrl("https://example.com/feed.xml");
|
||||
|
||||
subscription = new FeedSubscription();
|
||||
subscription.setId(1L);
|
||||
subscription.setTitle("My Feed");
|
||||
subscription.setUser(user);
|
||||
subscription.setNotifyOnNewEntries(true);
|
||||
|
||||
Content content = new Content("Article Title", "content", "author", null, null, null);
|
||||
entry = new Entry("guid-1", "https://example.com/article", Instant.now(), content);
|
||||
|
||||
feedEntry = new FeedEntry();
|
||||
feedEntry.setUrl("https://example.com/article");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSendsNotificationsForNewEntries() {
|
||||
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(null);
|
||||
Mockito.when(feedEntryService.create(feed, entry)).thenReturn(feedEntry);
|
||||
Mockito.when(feedEntryService.applyFilter(subscription, feedEntry)).thenReturn(true);
|
||||
|
||||
UserSettings settings = new UserSettings();
|
||||
settings.setNotificationEnabled(true);
|
||||
Mockito.when(userSettingsDAO.findByUser(user)).thenReturn(settings);
|
||||
|
||||
updater.update(feed, List.of(entry));
|
||||
|
||||
Mockito.verify(notificationService).notify(settings, subscription, feedEntry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDoesNotNotifyWhenSubscriptionNotifyDisabled() {
|
||||
subscription.setNotifyOnNewEntries(false);
|
||||
|
||||
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(null);
|
||||
Mockito.when(feedEntryService.create(feed, entry)).thenReturn(feedEntry);
|
||||
Mockito.when(feedEntryService.applyFilter(subscription, feedEntry)).thenReturn(true);
|
||||
|
||||
updater.update(feed, List.of(entry));
|
||||
|
||||
Mockito.verify(notificationService, Mockito.never()).notify(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDoesNotNotifyWhenUserNotificationsDisabled() {
|
||||
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(null);
|
||||
Mockito.when(feedEntryService.create(feed, entry)).thenReturn(feedEntry);
|
||||
Mockito.when(feedEntryService.applyFilter(subscription, feedEntry)).thenReturn(true);
|
||||
|
||||
UserSettings settings = new UserSettings();
|
||||
settings.setNotificationEnabled(false);
|
||||
Mockito.when(userSettingsDAO.findByUser(user)).thenReturn(settings);
|
||||
|
||||
updater.update(feed, List.of(entry));
|
||||
|
||||
Mockito.verify(notificationService, Mockito.never()).notify(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDoesNotNotifyWhenNoUserSettings() {
|
||||
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(null);
|
||||
Mockito.when(feedEntryService.create(feed, entry)).thenReturn(feedEntry);
|
||||
Mockito.when(feedEntryService.applyFilter(subscription, feedEntry)).thenReturn(true);
|
||||
|
||||
Mockito.when(userSettingsDAO.findByUser(user)).thenReturn(null);
|
||||
|
||||
updater.update(feed, List.of(entry));
|
||||
|
||||
Mockito.verify(notificationService, Mockito.never()).notify(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDoesNotNotifyForExistingEntries() {
|
||||
Mockito.when(feedSubscriptionDAO.findByFeed(feed)).thenReturn(List.of(subscription));
|
||||
Mockito.when(feedEntryService.find(feed, entry)).thenReturn(feedEntry);
|
||||
|
||||
updater.update(feed, List.of(entry));
|
||||
|
||||
Mockito.verify(notificationService, Mockito.never()).notify(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ class OPMLImporterTest {
|
||||
importer.importOpml(user, xml);
|
||||
|
||||
Mockito.verify(feedSubscriptionService)
|
||||
.subscribe(Mockito.eq(user), Mockito.anyString(), Mockito.anyString(), Mockito.any(FeedCategory.class), Mockito.anyInt());
|
||||
.subscribe(Mockito.eq(user), Mockito.anyString(), Mockito.anyString(), Mockito.any(FeedCategory.class), Mockito.anyInt(),
|
||||
Mockito.anyBoolean());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.commafeed.backend.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpResponse.BodyHandler;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.FeedEntryContent;
|
||||
import com.commafeed.backend.model.FeedSubscription;
|
||||
import com.commafeed.backend.model.UserSettings;
|
||||
import com.commafeed.backend.model.UserSettings.NotificationType;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NotificationServiceTest {
|
||||
|
||||
@Mock
|
||||
private HttpClient httpClient;
|
||||
|
||||
@Mock
|
||||
private HttpResponse<String> httpResponse;
|
||||
|
||||
private NotificationService notificationService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
notificationService = new NotificationService(httpClient);
|
||||
}
|
||||
|
||||
private void stubHttpClient() throws Exception {
|
||||
Mockito.when(httpResponse.statusCode()).thenReturn(200);
|
||||
Mockito.when(httpClient.send(Mockito.any(HttpRequest.class), Mockito.<BodyHandler<String>> any())).thenReturn(httpResponse);
|
||||
}
|
||||
|
||||
private HttpRequest captureRequest() throws Exception {
|
||||
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
|
||||
Mockito.verify(httpClient).send(captor.capture(), Mockito.<BodyHandler<String>> any());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendNtfyBuildsCorrectRequest() throws Exception {
|
||||
stubHttpClient();
|
||||
|
||||
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||
settings.setNotificationServerUrl("https://ntfy.example.com");
|
||||
settings.setNotificationTopic("my-topic");
|
||||
settings.setNotificationToken("my-token");
|
||||
|
||||
FeedSubscription sub = newSubscription("My Feed");
|
||||
FeedEntry entry = newEntry("New Article", "https://example.com/article");
|
||||
|
||||
notificationService.notify(settings, sub, entry);
|
||||
|
||||
HttpRequest request = captureRequest();
|
||||
Assertions.assertEquals("https://ntfy.example.com/my-topic", request.uri().toString());
|
||||
Assertions.assertEquals("My Feed: New Article", request.headers().firstValue("Title").orElse(null));
|
||||
Assertions.assertEquals("https://example.com/article", request.headers().firstValue("Click").orElse(null));
|
||||
Assertions.assertEquals("Bearer my-token", request.headers().firstValue("Authorization").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendNtfyOmitsOptionalHeaders() throws Exception {
|
||||
stubHttpClient();
|
||||
|
||||
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||
settings.setNotificationServerUrl("https://ntfy.example.com");
|
||||
settings.setNotificationTopic("my-topic");
|
||||
|
||||
FeedSubscription sub = newSubscription("My Feed");
|
||||
FeedEntry entry = newEntry("Title", "");
|
||||
|
||||
notificationService.notify(settings, sub, entry);
|
||||
|
||||
HttpRequest request = captureRequest();
|
||||
Assertions.assertTrue(request.headers().firstValue("Click").isEmpty());
|
||||
Assertions.assertTrue(request.headers().firstValue("Authorization").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendNtfySkipsWhenMissingConfig() throws Exception {
|
||||
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||
settings.setNotificationTopic("topic");
|
||||
notificationService.notify(settings, newSubscription("F"), newEntry("T", "U"));
|
||||
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||
|
||||
UserSettings settings2 = newSettings(NotificationType.NTFY);
|
||||
settings2.setNotificationServerUrl("https://ntfy.example.com");
|
||||
notificationService.notify(settings2, newSubscription("F"), newEntry("T", "U"));
|
||||
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendGotifyBuildsCorrectRequest() throws Exception {
|
||||
stubHttpClient();
|
||||
|
||||
UserSettings settings = newSettings(NotificationType.GOTIFY);
|
||||
settings.setNotificationServerUrl("https://gotify.example.com/");
|
||||
settings.setNotificationToken("app-token");
|
||||
|
||||
FeedSubscription sub = newSubscription("My Feed");
|
||||
FeedEntry entry = newEntry("New Article", "https://example.com/article");
|
||||
|
||||
notificationService.notify(settings, sub, entry);
|
||||
|
||||
HttpRequest request = captureRequest();
|
||||
Assertions.assertEquals("https://gotify.example.com/message", request.uri().toString());
|
||||
Assertions.assertEquals("app-token", request.headers().firstValue("X-Gotify-Key").orElse(null));
|
||||
Assertions.assertEquals("application/json", request.headers().firstValue("Content-Type").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendGotifySkipsWhenMissingConfig() throws Exception {
|
||||
UserSettings settings = newSettings(NotificationType.GOTIFY);
|
||||
settings.setNotificationToken("token");
|
||||
notificationService.notify(settings, newSubscription("F"), newEntry("T", "U"));
|
||||
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||
|
||||
UserSettings settings2 = newSettings(NotificationType.GOTIFY);
|
||||
settings2.setNotificationServerUrl("https://gotify.example.com");
|
||||
notificationService.notify(settings2, newSubscription("F"), newEntry("T", "U"));
|
||||
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPushoverBuildsCorrectRequest() throws Exception {
|
||||
stubHttpClient();
|
||||
|
||||
UserSettings settings = newSettings(NotificationType.PUSHOVER);
|
||||
settings.setNotificationToken("po-token");
|
||||
settings.setNotificationUserKey("po-user");
|
||||
|
||||
FeedSubscription sub = newSubscription("My Feed");
|
||||
FeedEntry entry = newEntry("New Article", "https://example.com/article");
|
||||
|
||||
notificationService.notify(settings, sub, entry);
|
||||
|
||||
HttpRequest request = captureRequest();
|
||||
Assertions.assertEquals("https://api.pushover.net/1/messages.json", request.uri().toString());
|
||||
Assertions.assertEquals("application/x-www-form-urlencoded", request.headers().firstValue("Content-Type").orElse(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPushoverOmitsUrlWhenBlank() throws Exception {
|
||||
stubHttpClient();
|
||||
|
||||
UserSettings settings = newSettings(NotificationType.PUSHOVER);
|
||||
settings.setNotificationToken("po-token");
|
||||
settings.setNotificationUserKey("po-user");
|
||||
|
||||
FeedSubscription sub = newSubscription("My Feed");
|
||||
FeedEntry entry = newEntry("Title", "");
|
||||
|
||||
notificationService.notify(settings, sub, entry);
|
||||
|
||||
Mockito.verify(httpClient).send(Mockito.any(HttpRequest.class), Mockito.<BodyHandler<String>> any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPushoverSkipsWhenMissingConfig() throws Exception {
|
||||
UserSettings settings = newSettings(NotificationType.PUSHOVER);
|
||||
settings.setNotificationUserKey("user");
|
||||
notificationService.notify(settings, newSubscription("F"), newEntry("T", "U"));
|
||||
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||
|
||||
UserSettings settings2 = newSettings(NotificationType.PUSHOVER);
|
||||
settings2.setNotificationToken("token");
|
||||
notificationService.notify(settings2, newSubscription("F"), newEntry("T", "U"));
|
||||
Mockito.verify(httpClient, Mockito.never()).send(Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyDoesNotPropagateExceptions() throws Exception {
|
||||
Mockito.when(httpClient.send(Mockito.any(HttpRequest.class), Mockito.<BodyHandler<String>> any()))
|
||||
.thenThrow(new IOException("connection failed"));
|
||||
|
||||
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||
settings.setNotificationServerUrl("https://ntfy.example.com");
|
||||
settings.setNotificationTopic("topic");
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> notificationService.notify(settings, newSubscription("Feed"), newEntry("Title", "url")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyUsesNewEntryAsFallbackTitle() throws Exception {
|
||||
stubHttpClient();
|
||||
|
||||
UserSettings settings = newSettings(NotificationType.NTFY);
|
||||
settings.setNotificationServerUrl("https://ntfy.example.com");
|
||||
settings.setNotificationTopic("topic");
|
||||
|
||||
FeedSubscription sub = newSubscription("Feed");
|
||||
|
||||
FeedEntry entryNoContent = new FeedEntry();
|
||||
entryNoContent.setUrl("https://example.com");
|
||||
notificationService.notify(settings, sub, entryNoContent);
|
||||
|
||||
HttpRequest request = captureRequest();
|
||||
Assertions.assertEquals("Feed: New entry", request.headers().firstValue("Title").orElse(null));
|
||||
}
|
||||
|
||||
private UserSettings newSettings(NotificationType type) {
|
||||
UserSettings settings = new UserSettings();
|
||||
settings.setNotificationEnabled(true);
|
||||
settings.setNotificationType(type);
|
||||
return settings;
|
||||
}
|
||||
|
||||
private FeedSubscription newSubscription(String title) {
|
||||
FeedSubscription sub = new FeedSubscription();
|
||||
sub.setTitle(title);
|
||||
return sub;
|
||||
}
|
||||
|
||||
private FeedEntry newEntry(String title, String url) {
|
||||
FeedEntryContent content = new FeedEntryContent();
|
||||
content.setTitle(title);
|
||||
FeedEntry entry = new FeedEntry();
|
||||
entry.setContent(content);
|
||||
entry.setUrl(url);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user