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

This commit is contained in:
John Brayton
2026-06-19 20:47:32 -04:00
committed by GitHub
parent 530b0c5739
commit bcb2cf2aa2
9 changed files with 206 additions and 17 deletions
+18
View File
@@ -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()
+21
View File
@@ -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
View File
@@ -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)
+63
View File
@@ -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() {
+14 -5
View File
@@ -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)
+1
View File
@@ -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.
+1 -11
View File
@@ -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
View File
@@ -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 {
+68
View File
@@ -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) {
for _, status := range []string{model.EntryStatusRead, model.EntryStatusUnread} {
if err := ValidateEntryStatus(status); err != nil {