fix: omit status on non-archive writes, skip finished in refresh and scan
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user