add support for declared icons in feeds (#2048)

This commit is contained in:
Athou
2026-05-10 16:24:25 +02:00
parent 1622eb642a
commit 4a8be29616
27 changed files with 715 additions and 295 deletions

View File

@@ -75,33 +75,4 @@ class FacebookFaviconFetcherTest {
Assertions.assertNull(faviconFetcher.fetch(feed));
}
@Test
void testFetchWithInvalidIconResponse() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://www.facebook.com/something?id=validUserId");
// Create a byte array that's too small
byte[] iconBytes = new byte[50];
String contentType = "image/png";
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://graph.facebook.com/validUserId/picture?type=square&height=16")).thenReturn(httpResult);
Assertions.assertNull(faviconFetcher.fetch(feed));
}
@Test
void testFetchWithBlacklistedContentType() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://www.facebook.com/something?id=validUserId");
byte[] iconBytes = new byte[1000];
String contentType = "application/xml"; // Blacklisted content type
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://graph.facebook.com/validUserId/picture?type=square&height=16")).thenReturn(httpResult);
Assertions.assertNull(faviconFetcher.fetch(feed));
}
}

View File

@@ -0,0 +1,69 @@
package com.commafeed.backend.favicon;
import java.time.Duration;
import jakarta.ws.rs.core.MediaType;
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.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.model.Feed;
@ExtendWith(MockitoExtension.class)
class FeedFaviconFetcherTest {
@Mock
private HttpGetter httpGetter;
private FeedFaviconFetcher faviconFetcher;
@BeforeEach
void init() {
faviconFetcher = new FeedFaviconFetcher(httpGetter);
}
@Test
void testFetchWithNullIconUrl() {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
Assertions.assertNull(faviconFetcher.fetch(feed));
Mockito.verifyNoInteractions(httpGetter);
}
@Test
void testFetchWithValidIconUrl() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
feed.setIconUrl("https://example.com/icon.png");
byte[] iconBytes = new byte[1000];
String contentType = "image/png";
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/icon.png")).thenReturn(httpResult);
Favicon result = faviconFetcher.fetch(feed);
Assertions.assertNotNull(result);
Assertions.assertEquals(iconBytes, result.icon());
Assertions.assertTrue(result.mediaType().isCompatible(MediaType.valueOf(contentType)));
}
@Test
void testFetchWithHttpGetterException() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
feed.setIconUrl("https://example.com/icon.png");
Mockito.when(httpGetter.get("https://example.com/icon.png")).thenThrow(new RuntimeException("Network error"));
Assertions.assertNull(faviconFetcher.fetch(feed));
}
}

View File

@@ -0,0 +1,122 @@
package com.commafeed.backend.favicon;
import java.time.Duration;
import jakarta.ws.rs.core.MediaType;
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.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.model.Feed;
@ExtendWith(MockitoExtension.class)
class HtmlFaviconFetcherTest {
@Mock
private HttpGetter httpGetter;
private HtmlFaviconFetcher faviconFetcher;
@BeforeEach
void init() {
faviconFetcher = new HtmlFaviconFetcher(httpGetter);
}
@Test
void testFetchWithNullLink() {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
Assertions.assertNull(faviconFetcher.fetch(feed));
Mockito.verifyNoInteractions(httpGetter);
}
@Test
void testFetchWithValidIconLink() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
feed.setLink("https://example.com");
String html = "<html><head><link rel=\"icon\" href=\"/favicon.png\" /></head><body></body></html>";
HttpResult pageResult = new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com")).thenReturn(pageResult);
byte[] iconBytes = new byte[1000];
String contentType = "image/png";
HttpResult iconResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/favicon.png")).thenReturn(iconResult);
Favicon result = faviconFetcher.fetch(feed);
Assertions.assertNotNull(result);
Assertions.assertEquals(iconBytes, result.icon());
Assertions.assertTrue(result.mediaType().isCompatible(MediaType.valueOf(contentType)));
}
@Test
void testFetchWithShortcutIconLink() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
feed.setLink("https://example.com");
String html = "<html><head><link rel=\"shortcut icon\" href=\"https://example.com/shortcut-favicon.ico\" /></head><body></body></html>";
HttpResult pageResult = new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com")).thenReturn(pageResult);
byte[] iconBytes = new byte[1000];
String contentType = "image/x-icon";
HttpResult iconResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/shortcut-favicon.ico")).thenReturn(iconResult);
Favicon result = faviconFetcher.fetch(feed);
Assertions.assertNotNull(result);
Assertions.assertEquals(iconBytes, result.icon());
Assertions.assertTrue(result.mediaType().isCompatible(MediaType.valueOf(contentType)));
}
@Test
void testFetchWithNoIconInPage() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
feed.setLink("https://example.com");
String html = "<html><head></head><body></body></html>";
HttpResult pageResult = new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com")).thenReturn(pageResult);
Assertions.assertNull(faviconFetcher.fetch(feed));
}
@Test
void testFetchWithPageFetchException() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
feed.setLink("https://example.com");
Mockito.when(httpGetter.get("https://example.com")).thenThrow(new RuntimeException("Network error"));
Assertions.assertNull(faviconFetcher.fetch(feed));
}
@Test
void testFetchWithIconFetchException() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed");
feed.setLink("https://example.com");
String html = "<html><head><link rel=\"icon\" href=\"https://example.com/favicon.png\" /></head><body></body></html>";
HttpResult pageResult = new HttpResult(html.getBytes(), "text/html", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com")).thenReturn(pageResult);
Mockito.when(httpGetter.get("https://example.com/favicon.png")).thenThrow(new RuntimeException("Network error"));
Assertions.assertNull(faviconFetcher.fetch(feed));
}
}

View File

@@ -0,0 +1,77 @@
package com.commafeed.backend.favicon;
import java.time.Duration;
import jakarta.ws.rs.core.MediaType;
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.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.model.Feed;
@ExtendWith(MockitoExtension.class)
class RootFaviconFetcherTest {
@Mock
private HttpGetter httpGetter;
private RootFaviconFetcher faviconFetcher;
@BeforeEach
void init() {
faviconFetcher = new RootFaviconFetcher(httpGetter);
}
@Test
void testFetchUsesLinkWhenAvailable() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://feeds.example.com/feed");
feed.setLink("https://www.example.com/blog");
byte[] iconBytes = new byte[1000];
String contentType = "image/x-icon";
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://www.example.com/favicon.ico")).thenReturn(httpResult);
Favicon result = faviconFetcher.fetch(feed);
Assertions.assertNotNull(result);
Assertions.assertEquals(iconBytes, result.icon());
Assertions.assertTrue(result.mediaType().isCompatible(MediaType.valueOf(contentType)));
}
@Test
void testFetchFallsBackToUrlWhenLinkIsNull() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed.xml");
byte[] iconBytes = new byte[1000];
String contentType = "image/x-icon";
HttpResult httpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/favicon.ico")).thenReturn(httpResult);
Favicon result = faviconFetcher.fetch(feed);
Assertions.assertNotNull(result);
Assertions.assertEquals(iconBytes, result.icon());
Assertions.assertTrue(result.mediaType().isCompatible(MediaType.valueOf(contentType)));
}
@Test
void testFetchWithHttpGetterException() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://example.com/feed.xml");
feed.setLink("https://example.com");
Mockito.when(httpGetter.get("https://example.com/favicon.ico")).thenThrow(new RuntimeException("Network error"));
Assertions.assertNull(faviconFetcher.fetch(feed));
}
}

View File

@@ -168,31 +168,6 @@ class YoutubeFaviconFetcherTest {
Assertions.assertNull(faviconFetcher.fetch(feed));
}
@Test
void testFetchWithInvalidIconResponse() throws Exception {
Feed feed = new Feed();
feed.setUrl("https://youtube.com/feeds/videos.xml?user=testUser");
Mockito.when(config.googleAuthKey()).thenReturn(Optional.of("test-api-key"));
byte[] apiResponse = """
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}""".getBytes();
HttpResult apiHttpResult = new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://www.googleapis.com/youtube/v3/channels?part=snippet&key=test-api-key&forUsername=testUser"))
.thenReturn(apiHttpResult);
JsonNode jsonNode = new ObjectMapper().readTree(apiResponse);
Mockito.when(objectMapper.readTree(apiResponse)).thenReturn(jsonNode);
// Create a byte array that's too small
byte[] iconBytes = new byte[50];
String contentType = "image/png";
HttpResult iconHttpResult = new HttpResult(iconBytes, contentType, null, null, null, Duration.ZERO);
Mockito.when(httpGetter.get("https://example.com/icon.png")).thenReturn(iconHttpResult);
Assertions.assertNull(faviconFetcher.fetch(feed));
}
@Test
void testFetchWithEmptyApiResponse() throws Exception {
Feed feed = new Feed();

View File

@@ -53,7 +53,7 @@ class FeedFetcherTest {
byte[] feed = "feed".getBytes();
Mockito.when(getter.get(HttpGetter.HttpRequest.builder(feedUrl).build()))
.thenReturn(new HttpResult(feed, "application/atom+xml", null, null, feedUrl, Duration.ZERO));
Mockito.when(parser.parse(feedUrl, feed)).thenReturn(new FeedParserResult("title", "link", null, null, null, null));
Mockito.when(parser.parse(feedUrl, feed)).thenReturn(new FeedParserResult("title", "link", "iconUrl", null, null, null, null));
Mockito.when(urlProvider.get(htmlUrl, new String(html))).thenReturn(List.of(feedUrl));

View File

@@ -0,0 +1,151 @@
package com.commafeed.backend.service;
import java.io.IOException;
import java.util.List;
import jakarta.ws.rs.core.MediaType;
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.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import com.commafeed.backend.favicon.Favicon;
import com.commafeed.backend.favicon.FaviconFetcher;
import com.commafeed.backend.model.Feed;
@ExtendWith(MockitoExtension.class)
class FeedFaviconServiceTest {
@Mock
private FaviconFetcher fetcher1;
@Mock
private FaviconFetcher fetcher2;
private FeedFaviconService service;
private Feed feed;
@BeforeEach
void init() throws IOException {
service = new FeedFaviconService(List.of(fetcher1, fetcher2));
feed = new Feed();
feed.setUrl("https://example.com/feed");
}
@Test
void testReturnsFirstValidFavicon() {
byte[] iconBytes = new byte[1000];
Favicon validFavicon = new Favicon(iconBytes, "image/png");
Mockito.when(fetcher1.fetch(feed)).thenReturn(validFavicon);
Favicon result = service.fetchFavicon(feed);
Assertions.assertEquals(validFavicon, result);
Mockito.verify(fetcher1).fetch(feed);
Mockito.verifyNoInteractions(fetcher2);
}
@Test
void testFallsBackToNextFetcherWhenFirstReturnsNull() {
byte[] iconBytes = new byte[1000];
Favicon validFavicon = new Favicon(iconBytes, "image/png");
Mockito.when(fetcher1.fetch(feed)).thenReturn(null);
Mockito.when(fetcher2.fetch(feed)).thenReturn(validFavicon);
Favicon result = service.fetchFavicon(feed);
Assertions.assertEquals(validFavicon, result);
}
@Test
void testFallsBackToNextFetcherWhenFirstReturnsTooSmallIcon() {
byte[] tinyIcon = new byte[50];
Favicon tinyFavicon = new Favicon(tinyIcon, "image/png");
byte[] validIcon = new byte[1000];
Favicon validFavicon = new Favicon(validIcon, "image/png");
Mockito.when(fetcher1.fetch(feed)).thenReturn(tinyFavicon);
Mockito.when(fetcher2.fetch(feed)).thenReturn(validFavicon);
Favicon result = service.fetchFavicon(feed);
Assertions.assertEquals(validFavicon, result);
}
@Test
void testFallsBackToNextFetcherWhenFirstReturnsTooLargeIcon() {
byte[] hugeIcon = new byte[100001];
Favicon hugeFavicon = new Favicon(hugeIcon, "image/png");
byte[] validIcon = new byte[1000];
Favicon validFavicon = new Favicon(validIcon, "image/png");
Mockito.when(fetcher1.fetch(feed)).thenReturn(hugeFavicon);
Mockito.when(fetcher2.fetch(feed)).thenReturn(validFavicon);
Favicon result = service.fetchFavicon(feed);
Assertions.assertEquals(validFavicon, result);
}
@Test
void testFallsBackToNextFetcherWhenFirstReturnsBlacklistedContentType() {
byte[] iconBytes = new byte[1000];
Favicon xmlFavicon = new Favicon(iconBytes, "application/xml");
byte[] validIcon = new byte[1000];
Favicon validFavicon = new Favicon(validIcon, "image/png");
Mockito.when(fetcher1.fetch(feed)).thenReturn(xmlFavicon);
Mockito.when(fetcher2.fetch(feed)).thenReturn(validFavicon);
Favicon result = service.fetchFavicon(feed);
Assertions.assertEquals(validFavicon, result);
}
@Test
void testFallsBackToNextFetcherWhenFirstReturnsHtmlContentType() {
byte[] iconBytes = new byte[1000];
Favicon htmlFavicon = new Favicon(iconBytes, "text/html");
byte[] validIcon = new byte[1000];
Favicon validFavicon = new Favicon(validIcon, "image/png");
Mockito.when(fetcher1.fetch(feed)).thenReturn(htmlFavicon);
Mockito.when(fetcher2.fetch(feed)).thenReturn(validFavicon);
Favicon result = service.fetchFavicon(feed);
Assertions.assertEquals(validFavicon, result);
}
@Test
void testReturnsDefaultFaviconWhenAllFetchersFail() {
Mockito.when(fetcher1.fetch(feed)).thenReturn(null);
Mockito.when(fetcher2.fetch(feed)).thenReturn(null);
Favicon result = service.fetchFavicon(feed);
Assertions.assertNotNull(result);
Assertions.assertTrue(result.mediaType().isCompatible(MediaType.valueOf("image/gif")));
Assertions.assertTrue(result.icon().length > 0);
}
@Test
void testReturnsDefaultFaviconWhenNoFetchersRegistered() throws IOException {
FeedFaviconService emptyService = new FeedFaviconService(List.of());
Favicon result = emptyService.fetchFavicon(feed);
Assertions.assertNotNull(result);
Assertions.assertTrue(result.mediaType().isCompatible(MediaType.valueOf("image/gif")));
}
}