feat: send notification for new entries with Gotify, ntfy or Pushover, configurable per feed.

This commit is contained in:
Louis POIROT--HATTERMANN
2026-02-15 17:19:43 +01:00
parent ca2c687f26
commit e54151d2eb
26 changed files with 974 additions and 30 deletions

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}