feat(api): Allow API client to set "starred" to true or false using the "PUT /v1/entries" endpoint
This commit is contained in:
@@ -1030,6 +1030,24 @@ func (c *Client) UpdateEntriesContext(ctx context.Context, entryIDs []int64, sta
|
||||
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.
|
||||
func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) {
|
||||
ctx, cancel := withDefaultTimeout()
|
||||
|
||||
@@ -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) {
|
||||
expected := &Entry{
|
||||
ID: 1,
|
||||
|
||||
+1
-1
@@ -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/entries/ids", handler.getEntryIDsHandler)
|
||||
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("PUT /v1/entries/{entryID}", handler.updateEntryHandler)
|
||||
mux.HandleFunc("PUT /v1/entries/{entryID}/bookmark", handler.toggleStarredHandler)
|
||||
|
||||
@@ -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) {
|
||||
testConfig := newIntegrationTestConfig()
|
||||
if !testConfig.isConfigured() {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
func (h *handler) setEntryStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *handler) setEntryStatusAndStarredHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var entriesStatusUpdateRequest model.EntriesStatusUpdateRequest
|
||||
if err := json_parser.NewDecoder(r.Body).Decode(&entriesStatusUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.ValidateEntriesStatusUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
if err := validator.ValidateEntriesStatusAndStarredUpdateRequest(&entriesStatusUpdateRequest); err != nil {
|
||||
response.JSONBadRequest(w, r, err)
|
||||
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)
|
||||
|
||||
@@ -80,6 +80,7 @@ type Entries []*Entry
|
||||
type EntriesStatusUpdateRequest struct {
|
||||
EntryIDs []int64 `json:"entry_ids"`
|
||||
Status string `json:"status"`
|
||||
Starred *bool `json:"starred"`
|
||||
}
|
||||
|
||||
// EntryUpdateRequest represents a request to update an entry.
|
||||
|
||||
@@ -451,20 +451,10 @@ func (s *Storage) SetEntriesStatusAndCountVisible(userID int64, entryIDs []int64
|
||||
// SetEntriesStarredState updates the starred state for the given list of entries.
|
||||
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)`
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ func ValidateEntriesStatusUpdateRequest(request *model.EntriesStatusUpdateReques
|
||||
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.
|
||||
func ValidateEntryStatus(status string) error {
|
||||
switch status {
|
||||
|
||||
@@ -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: ¬Starred,
|
||||
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) {
|
||||
for _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread} {
|
||||
if err := ValidateEntryStatus(status); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user