diff --git a/backend/main_test.go b/backend/main_test.go index d199eb7..dd87c8e 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -160,3 +160,39 @@ func TestPutStatusValidation(t *testing.T) { }) } } + +// A PUT that omits the status field entirely (what a userscript build +// predating the column sends) is the actual preserve path — the "" case +// above only exercises the fresh-INSERT default and never touches an +// existing bucket. This must both keep the archived bucket and still apply +// the chapter progress carried in the same request. +func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) { + srv := newTestServer(t) + + seed := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", + strings.NewReader(`{"title":"Solo","status":"archived"}`)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, auth(seed)) + if rr.Code != http.StatusOK { + t.Fatalf("seed status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) + } + + req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", + strings.NewReader(`{"title":"Solo","last_chapter":"12","last_chapter_num":12}`)) + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(req)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) + } + + var got Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Status != "archived" { + t.Fatalf("stored status = %q, want %q", got.Status, "archived") + } + if got.LastChapterNum != 12 { + t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum) + } +} diff --git a/backend/store.go b/backend/store.go index 39e40d9..e3df5d2 100644 --- a/backend/store.go +++ b/backend/store.go @@ -193,10 +193,11 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) { if latestChapterNum.Valid { b.LatestChapterNum = &latestChapterNum.Float64 } - // A NULL or empty bucket would leave the row in no list at all, so it - // reads as the default rather than being passed through. + // A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would + // leave the row in no list at all, so anything outside the three known + // buckets reads as the default rather than being passed through. b.Status = status.String - if b.Status == "" { + if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished { b.Status = statusReading } return b, nil diff --git a/backend/store_test.go b/backend/store_test.go index c1eddac..619a5ff 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -760,6 +760,39 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) { } } +// Mirrors the poller's read-modify-write in latest.go: Get the current row, +// mutate only the latest-chapter fields, and Upsert the whole struct back. +// cur.Status comes from Get (never empty — see scanBookmark), so it must +// round-trip through Upsert unchanged rather than being reset. +func TestLatestPollRoundTripPreservesArchived(t *testing.T) { + store := newTestStore(t) + base := Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), + } + if _, err := store.Upsert(base); err != nil { + t.Fatalf("seed: %v", err) + } + + cur, found, err := store.Get(base.Key) + if err != nil || !found { + t.Fatalf("Get: found=%v err=%v", found, err) + } + + num := 7.0 + cur.LatestChapter = "7" + cur.LatestChapterNum = &num + cur.UpdatedAt = time.Now().UnixMilli() + + stored, err := store.Upsert(cur) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if stored.Status != statusArchived { + t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived) + } +} + func TestUpsertReplacesStatusWhenGiven(t *testing.T) { store := newTestStore(t) base := Bookmark{ diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 791799b..ee2d1d8 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -271,11 +271,17 @@ return res.json(); } - async function apiPut(key, obj) { + // Only an explicit archive/restore has an opinion about the bucket. Every + // other write omits `status`, so the server keeps the stored one — otherwise + // a cached value would resend "finished" (which the API rejects with 400) or + // silently un-archive a series archived on another device. + async function apiPut(key, obj, { sendStatus = false } = {}) { + const body = Object.assign({}, obj); + if (!sendStatus) delete body.status; const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { method: "PUT", headers: authHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify(obj), + body: JSON.stringify(body), }); if (!res.ok) throw new Error("PUT /bookmarks " + res.status); return res.json(); @@ -433,7 +439,7 @@ upsertLocal(bm); render(); try { - const saved = await apiPut(bm.key, bm); + const saved = await apiPut(bm.key, bm, { sendStatus: true }); upsertLocal(saved); // adopt the stored row: the server owns updated_at render(); toast(next === "archived" ? "Archived" : "Back in your list"); @@ -489,6 +495,7 @@ const now = Date.now(); const due = state.list .filter((b) => b.site === site && b.series_url) + .filter((b) => statusOf(b) !== "finished") .filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS) .sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0)) .slice(0, LATEST_CHECK_BATCH);