Skip to content
46 changes: 46 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,52 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
return entry, nil
}

// UnreadEntryIDs returns the IDs of all unread entries for the current user.
func (c *Client) UnreadEntryIDs() ([]int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.UnreadEntryIDsContext(ctx)
}

// UnreadEntryIDsContext returns the IDs of all unread entries for the current user.
func (c *Client) UnreadEntryIDsContext(ctx context.Context) ([]int64, error) {
body, err := c.request.Get(ctx, "/v1/unread-entry-ids")
if err != nil {
return nil, err
}
defer body.Close()

var result []int64
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}

return result, nil
}

// StarredEntryIDs returns the IDs of all starred entries for the current user.
func (c *Client) StarredEntryIDs() ([]int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()
return c.StarredEntryIDsContext(ctx)
}

// StarredEntryIDsContext returns the IDs of all starred entries for the current user.
func (c *Client) StarredEntryIDsContext(ctx context.Context) ([]int64, error) {
body, err := c.request.Get(ctx, "/v1/starred-entry-ids")
if err != nil {
return nil, err
}
defer body.Close()

var result []int64
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, fmt.Errorf("miniflux: response error (%v)", err)
}

return result, nil
}

// Entries fetches entries using the given filter.
func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
ctx, cancel := withDefaultTimeout()
Expand Down
2 changes: 2 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
mux.HandleFunc("GET /v1/unread-entry-ids", handler.getUnreadEntryIDsHandler)
mux.HandleFunc("GET /v1/starred-entry-ids", handler.getStarredEntryIDsHandler)
mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
Expand Down
173 changes: 173 additions & 0 deletions internal/api/api_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2631,6 +2631,179 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
}
}

func TestGetUnreadEntryIDsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}

adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)

regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)

regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)

// A new user should have no unread entries.
entryIDs, err := regularUserClient.UnreadEntryIDs()
if err != nil {
t.Fatal(err)
}

if entryIDs == nil {
t.Fatal(`Entry IDs should not be nil`)
}

if len(entryIDs) != 0 {
t.Fatalf(`Expected no unread entry IDs for a new user, got %d`, len(entryIDs))
}

// Subscribe to a feed so there are unread entries.
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}

allEntries, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}

if len(allEntries.Entries) == 0 {
t.Fatal(`Expected feed to have entries`)
}

entryIDs, err = regularUserClient.UnreadEntryIDs()
if err != nil {
t.Fatal(err)
}

if len(entryIDs) != allEntries.Total {
t.Fatalf(`Expected %d unread entry IDs, got %d`, allEntries.Total, len(entryIDs))
}

// Mark one entry as read and verify the count decreases.
firstEntryID := allEntries.Entries[0].ID
if err := regularUserClient.UpdateEntries([]int64{firstEntryID}, miniflux.EntryStatusRead); err != nil {
t.Fatal(err)
}

entryIDs, err = regularUserClient.UnreadEntryIDs()
if err != nil {
t.Fatal(err)
}

if len(entryIDs) != allEntries.Total-1 {
t.Fatalf(`Expected %d unread entry IDs after marking one as read, got %d`, allEntries.Total-1, len(entryIDs))
}

for _, id := range entryIDs {
if id == firstEntryID {
t.Fatalf(`Entry ID %d should not appear in unread IDs after being marked as read`, firstEntryID)
}
}
}

func TestGetStarredEntryIDsEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}

adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)

regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)

regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)

// A new user should have no starred entries.
entryIDs, err := regularUserClient.StarredEntryIDs()
if err != nil {
t.Fatal(err)
}

if entryIDs == nil {
t.Fatal(`Entry IDs should not be nil`)
}

if len(entryIDs) != 0 {
t.Fatalf(`Expected no starred entry IDs for a new user, got %d`, len(entryIDs))
}

// Subscribe to a feed and star one entry.
feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}

allEntries, err := regularUserClient.FeedEntries(feedID, nil)
if err != nil {
t.Fatal(err)
}

if len(allEntries.Entries) == 0 {
t.Fatal(`Expected feed to have entries`)
}

// Starring one entry should appear in the results regardless of its read status.
firstEntryID := allEntries.Entries[0].ID
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
t.Fatal(err)
}

entryIDs, err = regularUserClient.StarredEntryIDs()
if err != nil {
t.Fatal(err)
}

if len(entryIDs) != 1 {
t.Fatalf(`Expected 1 starred entry ID, got %d`, len(entryIDs))
}

if entryIDs[0] != firstEntryID {
t.Fatalf(`Expected starred entry ID %d, got %d`, firstEntryID, entryIDs[0])
}

// Marking the entry as read should not remove it from starred results.
if err := regularUserClient.UpdateEntries([]int64{firstEntryID}, miniflux.EntryStatusRead); err != nil {
t.Fatal(err)
}

entryIDs, err = regularUserClient.StarredEntryIDs()
if err != nil {
t.Fatal(err)
}

if len(entryIDs) != 1 {
t.Fatalf(`Expected starred entry ID to persist after marking as read, got %d result(s)`, len(entryIDs))
}

// Unstarring the entry should remove it from the results.
if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
t.Fatal(err)
}

entryIDs, err = regularUserClient.StarredEntryIDs()
if err != nil {
t.Fatal(err)
}

if len(entryIDs) != 0 {
t.Fatalf(`Expected no starred entry IDs after unstarring, got %d`, len(entryIDs))
}
}

func TestUpdateEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
Expand Down
26 changes: 26 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,32 @@ func TestVersionHandler(t *testing.T) {
}
}

func TestGetUnreadEntryIDsHandlerRequiresAuthentication(t *testing.T) {
handler := NewHandler(nil, nil)

r := httptest.NewRequest(http.MethodGet, "/v1/unread-entry-ids", nil)
w := httptest.NewRecorder()

handler.ServeHTTP(w, r)

if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}

func TestGetStarredEntryIDsHandlerRequiresAuthentication(t *testing.T) {
handler := NewHandler(nil, nil)

r := httptest.NewRequest(http.MethodGet, "/v1/starred-entry-ids", nil)
w := httptest.NewRecorder()

handler.ServeHTTP(w, r)

if got := w.Code; got != http.StatusUnauthorized {
t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
}
}

func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
scenarios := []struct {
name string
Expand Down
32 changes: 32 additions & 0 deletions internal/api/entry_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,38 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
}

func (h *handler) getUnreadEntryIDsHandler(w http.ResponseWriter, r *http.Request) {
entryIDs, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithStatuses(model.EntryStatusUnread).
GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
}

if entryIDs == nil {
entryIDs = []int64{}
}

response.JSON(w, r, entryIDs)
}

func (h *handler) getStarredEntryIDsHandler(w http.ResponseWriter, r *http.Request) {
entryIDs, err := h.store.NewEntryQueryBuilder(request.UserID(r)).
WithStarred(true).
GetEntryIDs()
if err != nil {
response.JSONServerError(w, r, err)
return
}

if entryIDs == nil {
entryIDs = []int64{}
}

response.JSON(w, r, entryIDs)
}

func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
go h.store.FlushHistory(loggedUserID)
Expand Down
Loading