Archived and finished buckets, userscript nav chips #4
@@ -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 {
|
if latestChapterNum.Valid {
|
||||||
b.LatestChapterNum = &latestChapterNum.Float64
|
b.LatestChapterNum = &latestChapterNum.Float64
|
||||||
}
|
}
|
||||||
// A NULL or empty bucket would leave the row in no list at all, so it
|
// A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would
|
||||||
// reads as the default rather than being passed through.
|
// 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
|
b.Status = status.String
|
||||||
if b.Status == "" {
|
if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished {
|
||||||
b.Status = statusReading
|
b.Status = statusReading
|
||||||
}
|
}
|
||||||
return b, nil
|
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) {
|
func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
base := Bookmark{
|
base := Bookmark{
|
||||||
|
|||||||
@@ -271,11 +271,17 @@
|
|||||||
return res.json();
|
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), {
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify(obj),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -433,7 +439,7 @@
|
|||||||
upsertLocal(bm);
|
upsertLocal(bm);
|
||||||
render();
|
render();
|
||||||
try {
|
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
|
upsertLocal(saved); // adopt the stored row: the server owns updated_at
|
||||||
render();
|
render();
|
||||||
toast(next === "archived" ? "Archived" : "Back in your list");
|
toast(next === "archived" ? "Archived" : "Back in your list");
|
||||||
@@ -489,6 +495,7 @@
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const due = state.list
|
const due = state.list
|
||||||
.filter((b) => b.site === site && b.series_url)
|
.filter((b) => b.site === site && b.series_url)
|
||||||
|
.filter((b) => statusOf(b) !== "finished")
|
||||||
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
||||||
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
||||||
.slice(0, LATEST_CHECK_BATCH);
|
.slice(0, LATEST_CHECK_BATCH);
|
||||||
|
|||||||
Reference in New Issue
Block a user