diff --git a/backend/handlers.go b/backend/handlers.go index c427bf1..025d670 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -60,6 +60,21 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) { } } } + + // An empty status is "no opinion" and Upsert keeps the stored bucket. + // Finishing a series is a web-UI decision, so the JSON API refuses it + // rather than trusting every client to leave it alone. + switch b.Status { + case "", statusReading, statusArchived: + case statusFinished: + http.Error(w, "status "+statusFinished+" can only be set from the web UI", + http.StatusBadRequest) + return + default: + http.Error(w, "invalid status", http.StatusBadRequest) + return + } + // Candidate timestamp, not a decision: Upsert keeps the stored one unless // reading progress actually moved. Any client value is ignored. b.UpdatedAt = time.Now().UnixMilli() diff --git a/backend/main_test.go b/backend/main_test.go index 90145bb..d199eb7 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -1,6 +1,11 @@ package main import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" "time" ) @@ -113,3 +118,45 @@ func TestLoadLatestPollClampsAndFallsBack(t *testing.T) { }) } } + +func TestPutStatusValidation(t *testing.T) { + cases := []struct { + name string + status string + want int + }{ + {"empty is no opinion", "", http.StatusOK}, + {"reading", "reading", http.StatusOK}, + {"archived", "archived", http.StatusOK}, + {"finished is web-only", "finished", http.StatusBadRequest}, + {"garbage", "dropped", http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := newTestServer(t) + body := fmt.Sprintf(`{"title":"Solo","status":%q}`, tc.status) + req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", + strings.NewReader(body))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != tc.want { + t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String()) + } + if tc.want != http.StatusOK { + return + } + var got Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + want := tc.status + if want == "" { + want = "reading" + } + if got.Status != want { + t.Fatalf("stored status = %q, want %q", got.Status, want) + } + }) + } +}