Просмотр исходного кода

feat(api): Allow API client to set "starred" to true or false using the "PUT /v1/entries" endpoint

John Brayton 1 день назад
Родитель
Сommit
bcb2cf2aa2

+ 18 - 0
client/client.go

@@ -1030,6 +1030,24 @@ func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, sta
 	return err
 	return err
 }
 }
 
 
+// UpdateEntriesStarred updates the starred state of a list of entries.
+func (c *Client) UpdateEntriesStarred(entryIDs []int64, starred bool) error {
+	ctx, cancel := withDefaultTimeout()
+	defer cancel()
+	return c.UpdateEntriesStarredContext(ctx, entryIDs, starred)
+}
+
+// UpdateEntriesStarredContext updates the starred state of a list of entries.
+func (c *Client) UpdateEntriesStarredContext(ctx context.Context, entryIDs []int64, starred bool) error {
+	type payload struct {
+		EntryIDs []int64 `json:"entry_ids"`
+		Starred  *bool   `json:"starred"`
+	}
+
+	_, err := c.request.Put(ctx, "/v1/entries", &payload{EntryIDs: entryIDs, Starred: &starred})
+	return err
+}
+
 // UpdateEntry updates an entry.
 // UpdateEntry updates an entry.
 func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
 func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
 	ctx, cancel := withDefaultTimeout()
 	ctx, cancel := withDefaultTimeout()

+ 21 - 0
client/client_test.go

@@ -1108,6 +1108,27 @@ func TestUpdateEntries(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestUpdateEntriesStarred(t *testing.T) {
+	starred := true
+	client := NewClientWithOptions(
+		"http://mf",
+		WithHTTPClient(
+			newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
+				expectRequest(t, http.MethodPut, "http://mf/v1/entries", nil, req)
+				expectFromJSON(t, req.Body, &struct {
+					EntryIDs []int64 `json:"entry_ids"`
+					Starred  *bool   `json:"starred"`
+				}{
+					EntryIDs: []int64{1, 2},
+					Starred:  &starred,
+				})
+				return jsonResponseFrom(t, http.StatusOK, http.Header{}, nil)
+			})))
+	if err := client.UpdateEntriesStarredContext(t.Context(), []int64{1, 2}, true); err != nil {
+		t.Fatalf("Expected no error, got %v", err)
+	}
+}
+
 func TestUpdateEntry(t *testing.T) {
 func TestUpdateEntry(t *testing.T) {
 	expected := &Entry{
 	expected := &Entry{
 		ID:    1,
 		ID:    1,

+ 1 - 1
internal/api/api.go

@@ -56,7 +56,7 @@ func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
 	mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
 	mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
 	mux.HandleFunc("GET /v1/entries/ids", handler.getEntryIDsHandler)
 	mux.HandleFunc("GET /v1/entries/ids", handler.getEntryIDsHandler)
 	mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
 	mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
-	mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
+	mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusAndStarredHandler)
 	mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
 	mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
 	mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
 	mux.HandleFunc("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
 	mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
 	mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)

+ 63 - 0
internal/api/api_integration_test.go

@@ -2631,6 +2631,69 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestUpdateEntriesStarredEndpoint(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)
+
+	feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
+		FeedURL: testConfig.testFeedURL,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	result, err := regularUserClient.FeedEntries(feedID, nil)
+	if err != nil {
+		t.Fatalf(`Failed to get entries: %v`, err)
+	}
+
+	entryID := result.Entries[0].ID
+
+	// Star the entry without changing its status.
+	if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, true); err != nil {
+		t.Fatal(err)
+	}
+
+	entry, err := regularUserClient.Entry(entryID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if !entry.Starred {
+		t.Fatalf(`Expected entry to be starred`)
+	}
+
+	if entry.Status != miniflux.EntryStatusUnread {
+		t.Fatalf(`Expected status to remain unread, got %q`, entry.Status)
+	}
+
+	// Unstar the entry.
+	if err := regularUserClient.UpdateEntriesStarred([]int64{entryID}, false); err != nil {
+		t.Fatal(err)
+	}
+
+	entry, err = regularUserClient.Entry(entryID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if entry.Starred {
+		t.Fatalf(`Expected entry to no longer be starred`)
+	}
+}
+
 func TestGetEntryIDsEndpoint(t *testing.T) {
 func TestGetEntryIDsEndpoint(t *testing.T) {
 	testConfig := newIntegrationTestConfig()
 	testConfig := newIntegrationTestConfig()
 	if !testConfig.isConfigured() {
 	if !testConfig.isConfigured() {

+ 14 - 5
internal/api/entry_handlers.go

@@ -194,21 +194,30 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
 	response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
 	response.JSON(w, r, &entriesResponse{Total: count, Entries: entries})
 }
 }
 
 
-func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
+func (h *handler) setEntryStatusAndStarredHandler(w http.ResponseWriter, r *http.Request) {
 	var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
 	var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
 	if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
 	if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
 		response.JSONBadRequest(w, r, err)
 		response.JSONBadRequest(w, r, err)
 		return
 		return
 	}
 	}
 
 
-	if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
+	if err := validator.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
 		response.JSONBadRequest(w, r, err)
 		response.JSONBadRequest(w, r, err)
 		return
 		return
 	}
 	}
 
 
-	if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
-		response.JSONServerError(w, r, err)
-		return
+	if entriesStatusUpdateRequest.Status != "" {
+		if err := h.store.SetEntriesStatus(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, entriesStatusUpdateRequest.Status); err != nil {
+			response.JSONServerError(w, r, err)
+			return
+		}
+	}
+
+	if entriesStatusUpdateRequest.Starred != nil {
+		if err := h.store.SetEntriesStarredState(request.UserID(r), entriesStatusUpdateRequest.EntryIDs, *entriesStatusUpdateRequest.Starred); err != nil {
+			response.JSONServerError(w, r, err)
+			return
+		}
 	}
 	}
 
 
 	response.NoContent(w, r)
 	response.NoContent(w, r)

+ 1 - 0
internal/model/entry.go

@@ -80,6 +80,7 @@ type Entries []*Entry
 type EntriesStatusUpdateRequest struct {
 type EntriesStatusUpdateRequest struct {
 	EntryIDs []int64 `json:"entry_ids"`
 	EntryIDs []int64 `json:"entry_ids"`
 	Status   string  `json:"status"`
 	Status   string  `json:"status"`
+	Starred  *bool   `json:"starred"`
 }
 }
 
 
 // EntryUpdateRequest represents a request to update an entry.
 // EntryUpdateRequest represents a request to update an entry.

+ 1 - 11
internal/storage/entry.go

@@ -451,20 +451,10 @@ func (s *Storage) SetEntriesStatusAndCountVisible(userID int64, entryIDs []int64
 // SetEntriesStarredState updates the starred state for the given list of entries.
 // SetEntriesStarredState updates the starred state for the given list of entries.
 func (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {
 func (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {
 	query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
 	query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
-	result, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs))
-	if err != nil {
+	if _, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs)); err != nil {
 		return fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)
 		return fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)
 	}
 	}
 
 
-	count, err := result.RowsAffected()
-	if err != nil {
-		return fmt.Errorf(`store: unable to update these entries %v: %v`, entryIDs, err)
-	}
-
-	if count == 0 {
-		return errors.New(`store: nothing has been updated`)
-	}
-
 	return nil
 	return nil
 }
 }
 
 

+ 19 - 0
internal/validator/entry.go

@@ -19,6 +19,25 @@ func ValidateEntriesStatusUpdateRequest(request *model.EntriesStatusUpdateReques
 	return ValidateEntryStatus(request.Status)
 	return ValidateEntryStatus(request.Status)
 }
 }
 
 
+// ValidateEntriesStatusAndStarredUpdateRequest validates a status and/or starred update
+// for a list of entries. At least one of the status or starred fields must be specified.
+// This is used by the API, which can update the read status, the starred state, or both.
+func ValidateEntriesStatusAndStarredUpdateRequest(request *model.EntriesStatusUpdateRequest) error {
+	if len(request.EntryIDs) == 0 {
+		return errors.New(`the list of entries cannot be empty`)
+	}
+
+	if request.Status == "" && request.Starred == nil {
+		return errors.New(`either the status or the starred field must be specified`)
+	}
+
+	if request.Status != "" {
+		return ValidateEntryStatus(request.Status)
+	}
+
+	return nil
+}
+
 // ValidateEntryStatus makes sure the entry status is valid.
 // ValidateEntryStatus makes sure the entry status is valid.
 func ValidateEntryStatus(status string) error {
 func ValidateEntryStatus(status string) error {
 	switch status {
 	switch status {

+ 68 - 0
internal/validator/entry_test.go

@@ -34,6 +34,74 @@ func TestValidateEntriesStatusUpdateRequest(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestValidateEntriesStatusAndStarredUpdateRequest(t *testing.T) {
+	err := ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
+		Status:   model.EntryStatusRead,
+		EntryIDs: []int64{int64(123), int64(456)},
+	})
+	if err != nil {
+		t.Error(`A valid request should not be rejected`)
+	}
+
+	err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
+		Status: model.EntryStatusRead,
+	})
+	if err == nil {
+		t.Error(`An empty list of entries is not valid`)
+	}
+
+	err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
+		Status:   "invalid",
+		EntryIDs: []int64{int64(123)},
+	})
+	if err == nil {
+		t.Error(`Only a valid status should be accepted`)
+	}
+
+	starred := true
+	err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
+		Starred:  &starred,
+		EntryIDs: []int64{int64(123)},
+	})
+	if err != nil {
+		t.Error(`A request with only the starred field should be accepted`)
+	}
+
+	notStarred := false
+	err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
+		Starred:  &notStarred,
+		EntryIDs: []int64{int64(123)},
+	})
+	if err != nil {
+		t.Error(`A request with starred set to false should be accepted`)
+	}
+
+	err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
+		Status:   model.EntryStatusRead,
+		Starred:  &starred,
+		EntryIDs: []int64{int64(123)},
+	})
+	if err != nil {
+		t.Error(`A request with both status and starred should be accepted`)
+	}
+
+	err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
+		EntryIDs: []int64{int64(123)},
+	})
+	if err == nil {
+		t.Error(`A request without status or starred should be rejected`)
+	}
+
+	err = ValidateEntriesStatusAndStarredUpdateRequest(&model.EntriesStatusUpdateRequest{
+		Status:   "invalid",
+		Starred:  &starred,
+		EntryIDs: []int64{int64(123)},
+	})
+	if err == nil {
+		t.Error(`An invalid status should be rejected even when starred is specified`)
+	}
+}
+
 func TestValidateEntryStatus(t *testing.T) {
 func TestValidateEntryStatus(t *testing.T) {
 	for _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread} {
 	for _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread} {
 		if err := ValidateEntryStatus(status); err != nil {
 		if err := ValidateEntryStatus(status); err != nil {