From e17d395ae7b6a003efbd87d6df5c6fea054b9562 Mon Sep 17 00:00:00 2001 From: Benjamin Congdon Date: Wed, 25 Nov 2020 11:46:05 -0800 Subject: [PATCH] Add API routes for "mark all as read" --- api/api.go | 3 ++ api/category.go | 24 ++++++++++++++++ api/feed.go | 24 ++++++++++++++++ api/user.go | 20 ++++++++++++++ client/client.go | 18 ++++++++++++ tests/category_test.go | 34 +++++++++++++++++++++++ tests/feed_test.go | 32 ++++++++++++++++++++++ tests/user_test.go | 62 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 217 insertions(+) diff --git a/api/api.go b/api/api.go index d512bace..46921d71 100644 --- a/api/api.go +++ b/api/api.go @@ -29,12 +29,14 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool, feedHa sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods(http.MethodGet) sr.HandleFunc("/users/{userID:[0-9]+}", handler.updateUser).Methods(http.MethodPut) sr.HandleFunc("/users/{userID:[0-9]+}", handler.removeUser).Methods(http.MethodDelete) + sr.HandleFunc("/users/{userID:[0-9]+}/mark-all-as-read", handler.markUserAsRead).Methods(http.MethodPut) sr.HandleFunc("/users/{username}", handler.userByUsername).Methods(http.MethodGet) sr.HandleFunc("/me", handler.currentUser).Methods(http.MethodGet) sr.HandleFunc("/categories", handler.createCategory).Methods(http.MethodPost) sr.HandleFunc("/categories", handler.getCategories).Methods(http.MethodGet) sr.HandleFunc("/categories/{categoryID}", handler.updateCategory).Methods(http.MethodPut) sr.HandleFunc("/categories/{categoryID}", handler.removeCategory).Methods(http.MethodDelete) + sr.HandleFunc("/categories/{categoryID}/mark-all-as-read", handler.markCategoryAsRead).Methods(http.MethodPut) sr.HandleFunc("/discover", handler.getSubscriptions).Methods(http.MethodPost) sr.HandleFunc("/feeds", handler.createFeed).Methods(http.MethodPost) sr.HandleFunc("/feeds", handler.getFeeds).Methods(http.MethodGet) @@ -44,6 +46,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool, feedHa sr.HandleFunc("/feeds/{feedID}", handler.updateFeed).Methods(http.MethodPut) sr.HandleFunc("/feeds/{feedID}", handler.removeFeed).Methods(http.MethodDelete) sr.HandleFunc("/feeds/{feedID}/icon", handler.feedIcon).Methods(http.MethodGet) + sr.HandleFunc("/feeds/{feedID}/mark-all-as-read", handler.markFeedAsRead).Methods(http.MethodPut) sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet) sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost) sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet) diff --git a/api/category.go b/api/category.go index 2222369c..9a298e62 100644 --- a/api/category.go +++ b/api/category.go @@ -7,6 +7,7 @@ package api // import "miniflux.app/api" import ( "errors" "net/http" + "time" "miniflux.app/http/request" "miniflux.app/http/response/json" @@ -64,6 +65,29 @@ func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) { json.Created(w, r, category) } +func (h *handler) markCategoryAsRead(w http.ResponseWriter, r *http.Request) { + userID := request.UserID(r) + categoryID := request.RouteInt64Param(r, "categoryID") + + category, err := h.store.Category(userID, categoryID) + if err != nil { + json.ServerError(w, r, err) + return + } + + if category == nil { + json.NotFound(w, r) + return + } + + if err = h.store.MarkCategoryAsRead(userID, categoryID, time.Now()); err != nil { + json.ServerError(w, r, err) + return + } + + json.NoContent(w, r) +} + func (h *handler) getCategories(w http.ResponseWriter, r *http.Request) { categories, err := h.store.Categories(request.UserID(r)) if err != nil { diff --git a/api/feed.go b/api/feed.go index 31a33a0e..5bca6fc4 100644 --- a/api/feed.go +++ b/api/feed.go @@ -7,6 +7,7 @@ package api // import "miniflux.app/api" import ( "errors" "net/http" + "time" "miniflux.app/http/request" "miniflux.app/http/response/json" @@ -142,6 +143,29 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) { json.Created(w, r, originalFeed) } +func (h *handler) markFeedAsRead(w http.ResponseWriter, r *http.Request) { + feedID := request.RouteInt64Param(r, "feedID") + userID := request.UserID(r) + + feed, err := h.store.FeedByID(userID, feedID) + if err != nil { + json.NotFound(w, r) + return + } + + if feed == nil { + json.NotFound(w, r) + return + } + + if err := h.store.MarkFeedAsRead(userID, feedID, time.Now()); err != nil { + json.ServerError(w, r, err) + return + } + + json.NoContent(w, r) +} + func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) { feeds, err := h.store.Feeds(request.UserID(r)) if err != nil { diff --git a/api/user.go b/api/user.go index 01b32a6e..f0593f22 100644 --- a/api/user.go +++ b/api/user.go @@ -92,6 +92,26 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) { json.Created(w, r, originalUser) } +func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) { + userID := request.RouteInt64Param(r, "userID") + if userID != request.UserID(r) { + json.Forbidden(w, r) + return + } + + if _, err := h.store.UserByID(userID); err != nil { + json.NotFound(w, r) + return + } + + if err := h.store.MarkAllAsRead(userID); err != nil { + json.ServerError(w, r, err) + return + } + + json.NoContent(w, r) +} + func (h *handler) users(w http.ResponseWriter, r *http.Request) { if !request.IsAdminUser(r) { json.Forbidden(w, r) diff --git a/client/client.go b/client/client.go index e8b59f7a..60797f5c 100644 --- a/client/client.go +++ b/client/client.go @@ -133,6 +133,12 @@ func (c *Client) DeleteUser(userID int64) error { return c.request.Delete(fmt.Sprintf("/v1/users/%d", userID)) } +// MarkAllAsRead marks all unread entries as read for a given user. +func (c *Client) MarkAllAsRead(userID int64) error { + _, err := c.request.Put(fmt.Sprintf("/v1/users/%d/mark-all-as-read", userID), nil) + return err +} + // Discover try to find subscriptions from a website. func (c *Client) Discover(url string) (Subscriptions, error) { body, err := c.request.Post("/v1/discover", map[string]string{"url": url}) @@ -207,6 +213,12 @@ func (c *Client) UpdateCategory(categoryID int64, title string) (*Category, erro return category, nil } +// MarkCategoryAsRead marks all unread entries in a category as read. +func (c *Client) MarkCategoryAsRead(categoryID int64) error { + _, err := c.request.Put(fmt.Sprintf("/v1/categories/%d/mark-all-as-read", categoryID), nil) + return err +} + // DeleteCategory removes a category. func (c *Client) DeleteCategory(categoryID int64) error { return c.request.Delete(fmt.Sprintf("/v1/categories/%d", categoryID)) @@ -309,6 +321,12 @@ func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModification) (*Feed, return f, nil } +// MarkFeedAsRead marks all unread entries of the feed as read. +func (c *Client) MarkFeedAsRead(feedID int64) error { + _, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d/mark-all-as-read", feedID), nil) + return err +} + // RefreshAllFeeds refreshes all feeds. func (c *Client) RefreshAllFeeds() error { _, err := c.request.Put(fmt.Sprintf("/v1/feeds/refresh"), nil) diff --git a/tests/category_test.go b/tests/category_test.go index 8aa3d6b1..9345cacc 100644 --- a/tests/category_test.go +++ b/tests/category_test.go @@ -8,6 +8,8 @@ package tests import ( "testing" + + miniflux "miniflux.app/client" ) func TestCreateCategory(t *testing.T) { @@ -81,6 +83,38 @@ func TestUpdateCategory(t *testing.T) { } } +func TestMarkCategoryAsRead(t *testing.T) { + client := createClient(t) + + feed, category := createFeed(t, client) + + results, err := client.FeedEntries(feed.ID, nil) + if err != nil { + t.Fatalf(`Failed to get entries: %v`, err) + } + if results.Total == 0 { + t.Fatalf(`Invalid number of entries: %d`, results.Total) + } + if results.Entries[0].Status != miniflux.EntryStatusUnread { + t.Fatalf(`Invalid entry status, got %q instead of %q`, results.Entries[0].Status, miniflux.EntryStatusUnread) + } + + if err := client.MarkCategoryAsRead(category.ID); err != nil { + t.Fatalf(`Failed to mark category as read: %v`, err) + } + + results, err = client.FeedEntries(feed.ID, nil) + if err != nil { + t.Fatalf(`Failed to get updated entries: %v`, err) + } + + for _, entry := range results.Entries { + if entry.Status != miniflux.EntryStatusRead { + t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead) + } + } +} + func TestListCategories(t *testing.T) { categoryName := "My category" client := createClient(t) diff --git a/tests/feed_test.go b/tests/feed_test.go index e7841a4a..e4905df2 100644 --- a/tests/feed_test.go +++ b/tests/feed_test.go @@ -324,6 +324,38 @@ func TestUpdateFeedCategory(t *testing.T) { } } +func TestMarkFeedAsRead(t *testing.T) { + client := createClient(t) + + feed, _ := createFeed(t, client) + + results, err := client.FeedEntries(feed.ID, nil) + if err != nil { + t.Fatalf(`Failed to get entries: %v`, err) + } + if results.Total == 0 { + t.Fatalf(`Invalid number of entries: %d`, results.Total) + } + if results.Entries[0].Status != miniflux.EntryStatusUnread { + t.Fatalf(`Invalid entry status, got %q instead of %q`, results.Entries[0].Status, miniflux.EntryStatusUnread) + } + + if err := client.MarkFeedAsRead(feed.ID); err != nil { + t.Fatalf(`Failed to mark feed as read: %v`, err) + } + + results, err = client.FeedEntries(feed.ID, nil) + if err != nil { + t.Fatalf(`Failed to get updated entries: %v`, err) + } + + for _, entry := range results.Entries { + if entry.Status != miniflux.EntryStatusRead { + t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead) + } + } +} + func TestDeleteFeed(t *testing.T) { client := createClient(t) feed, _ := createFeed(t, client) diff --git a/tests/user_test.go b/tests/user_test.go index d2ff3477..26f4c855 100644 --- a/tests/user_test.go +++ b/tests/user_test.go @@ -394,3 +394,65 @@ func TestCannotDeleteUserAsNonAdmin(t *testing.T) { t.Fatal(`A "Forbidden" error should be raised`) } } + +func TestMarkUserAsReadAsUser(t *testing.T) { + username := getRandomUsername() + adminClient := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) + user, err := adminClient.CreateUser(username, testStandardPassword, false) + if err != nil { + t.Fatal(err) + } + + client := miniflux.New(testBaseURL, username, testStandardPassword) + feed, _ := createFeed(t, client) + + results, err := client.FeedEntries(feed.ID, nil) + if err != nil { + t.Fatalf(`Failed to get entries: %v`, err) + } + if results.Total == 0 { + t.Fatalf(`Invalid number of entries: %d`, results.Total) + } + if results.Entries[0].Status != miniflux.EntryStatusUnread { + t.Fatalf(`Invalid entry status, got %q instead of %q`, results.Entries[0].Status, miniflux.EntryStatusUnread) + } + + if err := client.MarkAllAsRead(user.ID); err != nil { + t.Fatalf(`Failed to mark user's unread entries as read: %v`, err) + } + + results, err = client.FeedEntries(feed.ID, nil) + if err != nil { + t.Fatalf(`Failed to get updated entries: %v`, err) + } + + for _, entry := range results.Entries { + if entry.Status != miniflux.EntryStatusRead { + t.Errorf(`Status for entry %d was %q instead of %q`, entry.ID, entry.Status, miniflux.EntryStatusRead) + } + } +} + +func TestCannotMarkUserAsReadAsOtherUser(t *testing.T) { + username := getRandomUsername() + adminClient := miniflux.New(testBaseURL, testAdminUsername, testAdminPassword) + user1, err := adminClient.CreateUser(username, testStandardPassword, false) + if err != nil { + t.Fatal(err) + } + createFeed(t, miniflux.New(testBaseURL, username, testStandardPassword)) + + username2 := getRandomUsername() + if _, err = adminClient.CreateUser(username2, testStandardPassword, false); err != nil { + t.Fatal(err) + } + + client := miniflux.New(testBaseURL, username2, testStandardPassword) + err = client.MarkAllAsRead(user1.ID) + if err == nil { + t.Fatalf(`Non-admin users should not be able to mark another user as read`) + } + if err != miniflux.ErrForbidden { + t.Errorf(`A "Forbidden" error should be raised, got %q`, err) + } +}