diff --git a/commafeed-server/src/test/java/com/commafeed/backend/service/db/DatabaseCleaningServiceTest.java b/commafeed-server/src/test/java/com/commafeed/backend/service/db/DatabaseCleaningServiceTest.java new file mode 100644 index 00000000..a6eb2f90 --- /dev/null +++ b/commafeed-server/src/test/java/com/commafeed/backend/service/db/DatabaseCleaningServiceTest.java @@ -0,0 +1,153 @@ +package com.commafeed.backend.service.db; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +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.Meter; +import com.codahale.metrics.MetricRegistry; +import com.commafeed.CommaFeedConfiguration; +import com.commafeed.backend.dao.FeedDAO; +import com.commafeed.backend.dao.FeedEntryContentDAO; +import com.commafeed.backend.dao.FeedEntryDAO; +import com.commafeed.backend.dao.FeedEntryDAO.FeedCapacity; +import com.commafeed.backend.dao.FeedEntryStatusDAO; +import com.commafeed.backend.dao.UnitOfWork; +import com.commafeed.backend.model.Feed; + +@ExtendWith(MockitoExtension.class) +class DatabaseCleaningServiceTest { + + private static final int BATCH_SIZE = 100; + + @Mock + private CommaFeedConfiguration config; + + @Mock + private CommaFeedConfiguration.Database databaseConfig; + + @Mock + private CommaFeedConfiguration.Database.Cleanup cleaningConfig; + + @Mock + private UnitOfWork unitOfWork; + + @Mock + private FeedDAO feedDAO; + + @Mock + private FeedEntryDAO feedEntryDAO; + + @Mock + private FeedEntryContentDAO feedEntryContentDAO; + + @Mock + private FeedEntryStatusDAO feedEntryStatusDAO; + + @Mock + private MetricRegistry metrics; + + @Mock + private Meter entriesDeletedMeter; + + private DatabaseCleaningService service; + + @BeforeEach + void setUp() { + Mockito.when(config.database()).thenReturn(databaseConfig); + Mockito.when(databaseConfig.cleanup()).thenReturn(cleaningConfig); + Mockito.when(cleaningConfig.batchSize()).thenReturn(BATCH_SIZE); + Mockito.when(metrics.meter(Mockito.anyString())).thenReturn(entriesDeletedMeter); + + Mockito.when(unitOfWork.call(Mockito.any())).thenAnswer(invocation -> ((Callable) invocation.getArgument(0)).call()); + + service = new DatabaseCleaningService(config, unitOfWork, feedDAO, feedEntryDAO, feedEntryContentDAO, feedEntryStatusDAO, metrics); + } + + @Test + void cleanFeedsWithoutSubscriptionsDeletesFeedsAndEntries() { + Feed feed1 = Mockito.mock(Feed.class); + Feed feed2 = Mockito.mock(Feed.class); + Mockito.when(feed1.getId()).thenReturn(1L); + Mockito.when(feed2.getId()).thenReturn(2L); + + // First iteration returns feeds, second returns empty list to terminate loop + Mockito.when(feedDAO.findWithoutSubscriptions(Mockito.anyInt())) + .thenReturn(Arrays.asList(feed1, feed2)) + .thenReturn(Collections.emptyList()); + + Mockito.when(feedEntryDAO.delete(1L, BATCH_SIZE)).thenReturn(10, 0); + Mockito.when(feedEntryDAO.delete(2L, BATCH_SIZE)).thenReturn(5, 0); + Mockito.when(feedDAO.delete(Mockito.anyList())).thenReturn(2, 0); + + service.cleanFeedsWithoutSubscriptions(); + + Mockito.verify(entriesDeletedMeter, Mockito.times(4)).mark(Mockito.anyLong()); + Mockito.verify(feedDAO, Mockito.times(2)).delete(Mockito.anyList()); + } + + @Test + void cleanContentsWithoutEntriesDeletesContents() { + Mockito.when(feedEntryContentDAO.deleteWithoutEntries(Mockito.anyInt())).thenReturn(50L, 30L, 0L); + + service.cleanContentsWithoutEntries(); + + Mockito.verify(feedEntryContentDAO, Mockito.times(3)).deleteWithoutEntries(Mockito.anyInt()); + } + + @Test + void cleanEntriesForFeedsExceedingCapacityDeletesOldEntries() { + FeedCapacity feed1 = Mockito.mock(FeedCapacity.class); + Mockito.when(feed1.getId()).thenReturn(1L); + Mockito.when(feed1.getCapacity()).thenReturn(180L); + + FeedCapacity feed2 = Mockito.mock(FeedCapacity.class); + Mockito.when(feed2.getId()).thenReturn(2L); + Mockito.when(feed2.getCapacity()).thenReturn(120L); + + Mockito.when(feedEntryDAO.findFeedsExceedingCapacity(50, BATCH_SIZE)) + .thenReturn(Arrays.asList(feed1, feed2)) + .thenReturn(Collections.emptyList()); + + Mockito.when(feedEntryDAO.deleteOldEntries(1L, 100)).thenReturn(80); + Mockito.when(feedEntryDAO.deleteOldEntries(1L, 50)).thenReturn(50); + Mockito.when(feedEntryDAO.deleteOldEntries(2L, 70)).thenReturn(70); + + service.cleanEntriesForFeedsExceedingCapacity(50); + + Mockito.verify(entriesDeletedMeter, Mockito.times(3)).mark(Mockito.anyLong()); + } + + @Test + void cleanEntriesOlderThanDeletesOldEntries() { + Instant cutoff = LocalDate.now().minusDays(30).atStartOfDay().toInstant(ZoneOffset.UTC); + + Mockito.when(feedEntryDAO.deleteEntriesOlderThan(cutoff, BATCH_SIZE)).thenReturn(100, 50, 0); + + service.cleanEntriesOlderThan(cutoff); + + Mockito.verify(feedEntryDAO, Mockito.times(3)).deleteEntriesOlderThan(cutoff, BATCH_SIZE); + Mockito.verify(entriesDeletedMeter, Mockito.times(3)).mark(Mockito.anyLong()); + } + + @Test + void cleanStatusesOlderThanDeletesOldStatuses() { + Instant cutoff = LocalDate.now().minusDays(60).atStartOfDay().toInstant(ZoneOffset.UTC); + + Mockito.when(feedEntryStatusDAO.deleteOldStatuses(cutoff, BATCH_SIZE)).thenReturn(200L, 100L, 0L); + + service.cleanStatusesOlderThan(cutoff); + + Mockito.verify(feedEntryStatusDAO, Mockito.times(3)).deleteOldStatuses(cutoff, BATCH_SIZE); + } +} \ No newline at end of file diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/FeverIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/FeverIT.java index fb3969f5..85baf445 100644 --- a/commafeed-server/src/test/java/com/commafeed/integration/rest/FeverIT.java +++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/FeverIT.java @@ -7,20 +7,24 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import com.commafeed.backend.Digests; +import com.commafeed.frontend.model.Entry; import com.commafeed.frontend.model.UserModel; import com.commafeed.frontend.model.request.ProfileModificationRequest; +import com.commafeed.frontend.model.request.StarRequest; import com.commafeed.frontend.resource.fever.FeverResponse; +import com.commafeed.frontend.resource.fever.FeverResponse.FeverItem; import com.commafeed.integration.BaseIT; import io.quarkus.test.junit.QuarkusTest; import io.restassured.RestAssured; import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import lombok.Setter; @QuarkusTest class FeverIT extends BaseIT { - private Long userId; - private String apiKey; + private FeverClient client; @BeforeEach void setup() { @@ -34,8 +38,7 @@ class FeverIT extends BaseIT { // retrieve api key UserModel user = RestAssured.given().get("rest/user/profile").then().statusCode(HttpStatus.SC_OK).extract().as(UserModel.class); - this.apiKey = user.getApiKey(); - this.userId = user.getId(); + this.client = new FeverClient(user.getId(), user.getApiKey()); } @AfterEach @@ -45,65 +48,155 @@ class FeverIT extends BaseIT { @Test void invalidApiKey() { - FeverResponse response = fetch("feeds", "invalid-key"); + client.apiKey = "invalid-key"; + + FeverResponse response = client.execute("feeds"); Assertions.assertFalse(response.isAuth()); } @Test void validApiKey() { - FeverResponse response = fetch("feeds", apiKey); + FeverResponse response = client.execute("feeds"); Assertions.assertTrue(response.isAuth()); } @Test void feeds() { subscribe(getFeedUrl()); - FeverResponse feverResponse = fetch("feeds"); + FeverResponse feverResponse = client.execute("feeds"); Assertions.assertEquals(1, feverResponse.getFeeds().size()); } @Test void unreadEntries() { subscribeAndWaitForEntries(getFeedUrl()); - FeverResponse feverResponse = fetch("unread_item_ids"); + FeverResponse feverResponse = client.execute("unread_item_ids"); Assertions.assertEquals(2, feverResponse.getUnreadItemIds().size()); } @Test void entries() { - subscribeAndWaitForEntries(getFeedUrl()); - FeverResponse feverResponse = fetch("items"); + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + FeverResponse feverResponse = client.execute("items"); Assertions.assertEquals(2, feverResponse.getItems().size()); + + FeverItem item = feverResponse.getItems().get(0); + Assertions.assertEquals(subscriptionId, item.getFeedId()); + Assertions.assertEquals("Item 2", item.getTitle()); + Assertions.assertEquals("Item 2 description", item.getHtml()); + Assertions.assertEquals("https://hostname.local/commafeed/2", item.getUrl()); + Assertions.assertFalse(item.isSaved()); + Assertions.assertFalse(item.isRead()); + } + + @Test + void entriesByIds() { + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + Entry entry = getFeedEntries(subscriptionId).getEntries().get(0); + + FeverResponse feverResponse = client.execute("items", new Param("with_ids", entry.getId())); + Assertions.assertEquals(1, feverResponse.getItems().size()); + } + + @Test + void savedEntries() { + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + Entry entry = getFeedEntries(subscriptionId).getEntries().get(0); + + StarRequest starRequest = new StarRequest(); + starRequest.setId(entry.getId()); + starRequest.setFeedId(subscriptionId); + starRequest.setStarred(true); + RestAssured.given().body(starRequest).contentType(ContentType.JSON).post("rest/entry/star"); + + FeverResponse feverResponse = client.execute("saved_item_ids"); + Assertions.assertEquals(1, feverResponse.getSavedItemIds().size()); + } + + @Test + void markEntry() { + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + Entry entry = getFeedEntries(subscriptionId).getEntries().get(0); + + client.execute("_", new Param("mark", "item"), new Param("id", entry.getId()), new Param("as", "read")); + Assertions.assertEquals(1, getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count()); + + client.execute("_", new Param("mark", "item"), new Param("id", entry.getId()), new Param("as", "unread")); + Assertions.assertEquals(0, getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count()); + } + + @Test + void markFeed() { + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + + client.execute("_", new Param("mark", "feed"), new Param("id", String.valueOf(subscriptionId)), new Param("as", "read")); + + Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead)); + } + + @Test + void markCategory() { + String categoryId = createCategory("test-category"); + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl(), categoryId); + + client.execute("_", new Param("mark", "group"), new Param("id", String.valueOf(categoryId)), new Param("as", "read")); + + Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead)); + } + + @Test + void tagEntry() { + Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); + Entry entry = getFeedEntries(subscriptionId).getEntries().get(0); + + client.execute("_", new Param("mark", "item"), new Param("id", entry.getId()), new Param("as", "saved")); + Assertions.assertEquals(1, getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isStarred).count()); + + client.execute("_", new Param("mark", "item"), new Param("id", entry.getId()), new Param("as", "unsaved")); + Assertions.assertEquals(0, getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isStarred).count()); } @Test void groups() { createCategory("category-1"); - FeverResponse feverResponse = fetch("groups"); + FeverResponse feverResponse = client.execute("groups"); Assertions.assertEquals(1, feverResponse.getGroups().size()); Assertions.assertEquals("category-1", feverResponse.getGroups().get(0).getTitle()); } @Test void links() { - FeverResponse feverResponse = fetch("links"); + FeverResponse feverResponse = client.execute("links"); Assertions.assertTrue(feverResponse.getLinks().isEmpty()); } - private FeverResponse fetch(String what) { - return fetch(what, apiKey); + private static class FeverClient { + private final Long userId; + + @Setter + private String apiKey; + + public FeverClient(Long userId, String apiKey) { + this.userId = userId; + this.apiKey = apiKey; + } + + private FeverResponse execute(String action, Param... params) { + RequestSpecification spec = RestAssured.given() + .auth() + .none() + .formParam("api_key", Digests.md5Hex("admin:" + apiKey)) + .formParam(action, 1); + + for (Param param : params) { + spec.formParam(param.name(), param.value()); + } + + return spec.post("rest/fever/user/{userId}", userId).then().statusCode(HttpStatus.SC_OK).extract().as(FeverResponse.class); + + } } - private FeverResponse fetch(String what, String apiKey) { - return RestAssured.given() - .auth() - .none() - .formParam("api_key", Digests.md5Hex("admin:" + apiKey)) - .formParam(what, 1) - .post("rest/fever/user/{userId}", userId) - .then() - .statusCode(HttpStatus.SC_OK) - .extract() - .as(FeverResponse.class); + private record Param(String name, String value) { } }