Archived and finished buckets, userscript nav chips #4
@@ -79,6 +79,7 @@ func (h *webHandler) register(mux *http.ServeMux) {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
|
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}/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("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
||||||
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
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)
|
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.
|
// uiChapter forces the read chapter to a value the user typed.
|
||||||
//
|
//
|
||||||
// Writing the number also clears last_chapter_url: that URL points at the
|
// Writing the number also clears last_chapter_url: that URL points at the
|
||||||
|
|||||||
@@ -599,3 +599,79 @@ func TestRecentStripExcludesArchivedAndFinished(t *testing.T) {
|
|||||||
t.Fatal("recent strip dropped the reading series")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user