diff --git a/backend/web.go b/backend/web.go index 952ff7d..7a5487d 100644 --- a/backend/web.go +++ b/backend/web.go @@ -79,6 +79,7 @@ func (h *webHandler) register(mux *http.ServeMux) { mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList)) mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite)) + mux.HandleFunc("POST /ui/bookmarks/{key}/status", h.requireSession(h.uiStatus)) mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter)) mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete)) } @@ -283,6 +284,32 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) { h.saveAndRenderCard(w, b) } +// uiStatus moves a bookmark between lifecycle buckets. This is the only place +// a series can be marked finished — the JSON API refuses that value, so the +// userscript cannot set it even by accident. +// +// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the +// list does not reorder. +func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) { + b, ok := h.loadForMutation(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + switch s := r.PostFormValue("status"); s { + case statusReading, statusArchived, statusFinished: + b.Status = s + default: + http.Error(w, "invalid status", http.StatusBadRequest) + return + } + b.UpdatedAt = time.Now().UnixMilli() + h.saveAndRenderCard(w, b) +} + // uiChapter forces the read chapter to a value the user typed. // // Writing the number also clears last_chapter_url: that URL points at the diff --git a/backend/web_test.go b/backend/web_test.go index b107630..315c07f 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -599,3 +599,79 @@ func TestRecentStripExcludesArchivedAndFinished(t *testing.T) { t.Fatal("recent strip dropped the reading series") } } + +func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"status": {status}} + req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/"+key+"/status", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie(t, cfg)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + return rr +} + +func TestUIStatusSetsBucket(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + for _, want := range []string{statusArchived, statusFinished, statusReading} { + if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK { + t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String()) + } + b, ok, err := store.Get("asura:reading") + if err != nil || !ok { + t.Fatalf("Get: ok=%v err=%v", ok, err) + } + if b.Status != want { + t.Fatalf("stored status = %q, want %q", b.Status, want) + } + } +} + +func TestUIStatusRejectsUnknownValue(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rr.Code) + } + b, _, _ := store.Get("asura:reading") + if b.Status != statusReading { + t.Fatalf("stored status = %q, want it untouched", b.Status) + } +} + +func TestUIStatusRequiresSession(t *testing.T) { + srv, store := newWebTestServer(t, webConfig()) + seedStatusRows(t, store) + + req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status", + strings.NewReader("status=archived")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rr.Code) + } +} + +func TestUIStatusDoesNotReorderList(t *testing.T) { + cfg := webConfig() + srv, store := newWebTestServer(t, cfg) + seedStatusRows(t, store) + + before, _, _ := store.Get("asura:reading") + time.Sleep(2 * time.Millisecond) + if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + after, _, _ := store.Get("asura:reading") + if after.UpdatedAt != before.UpdatedAt { + t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt) + } +}