fix: final review — cached links keep their expiry; history removal sticks; saves flushed
- A link reused from the rehost cache keeps the expiry it was uploaded with, and links near expiry are uploaded again (a dead litterbox link could be filed). - History saving moves into HistoryKeeper (tested): removing an entry or clearing the history cancels a pending save and stops tracking the series on screen; a pending edit is saved before a new search, before opening another entry and when the page is hidden; opening an entry no longer re-saves the current one. - The "couldn't be saved" line sits outside the History list, so it shows even when the list is empty; a refused IndexedDB says history is off. - Recently searched bands wrap their meta line instead of cutting the count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1cf31b26cf
commit
0d4a1a61fa
8 files changed
+362
-68
No files matched your search
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { SourceSeries } from "../src/core/model";
|
||||
import type { RunInput, RunResult } from "../src/core/pipeline";
|
||||
import { memoryHistory, type HistoryStore } from "../src/core/saved";
|
||||
import { Session, memoryStore } from "../src/core/session";
|
||||
import { webtoons } from "../src/core/sources/webtoons";
|
||||
import { HistoryKeeper } from "../src/web/history-keeper";
|
||||
import { inspected } from "./helpers/covers";
|
||||
import { FakeTransport } from "./helpers/fake-transport";
|
||||
|
||||
const series: SourceSeries = {
|
||||
source: "webtoons", externalId: "95", title: "Tower of God", language: "en",
|
||||
canonicalUrl: "https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95", archiveUrls: [], mature: false, flags: {},
|
||||
};
|
||||
const input: RunInput = { mangabakaLink: "https://mangabaka.org/manhwa/9999/Tower-of-God", sourceLink: series.canonicalUrl, history: null };
|
||||
function result(seriesId = 9999): RunResult {
|
||||
const poster = inspected();
|
||||
return {
|
||||
seriesId, mbSeries: { id: seriesId, title: `Series ${seriesId}`, type: "manhwa", content_rating: "safe" }, source: webtoons, series,
|
||||
linkMatches: true, fresh: [poster, inspected()], present: [], suggestedDefault: poster, warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A keeper over a session that "ran" `r`; timers are manual so debouncing is deterministic. */
|
||||
function setup(store: HistoryStore = memoryHistory()) {
|
||||
const session = new Session({ transport: new FakeTransport(), serverBase: null, catboxVia: "direct", store: memoryStore(), persistHostedUrls: false });
|
||||
const timers: (() => void)[] = [];
|
||||
const errors: (string | null)[] = [];
|
||||
const keeper = new HistoryKeeper(store, session, {
|
||||
rating: () => "safe",
|
||||
onStatus: (e) => errors.push(e),
|
||||
schedule: (fn) => {
|
||||
timers.push(fn);
|
||||
return timers.length;
|
||||
},
|
||||
cancel: (id) => {
|
||||
timers[(id as number) - 1] = () => {};
|
||||
},
|
||||
});
|
||||
const ran = async (r: RunResult) => {
|
||||
session.load(r);
|
||||
session.input = input;
|
||||
await keeper.searched(r, Date.now());
|
||||
};
|
||||
const fire = () => timers.splice(0).forEach((fn) => fn());
|
||||
return { session, keeper, store, ran, fire, errors };
|
||||
}
|
||||
|
||||
describe("history keeper", () => {
|
||||
test("a search is saved at once; edits are saved after the pause", async () => {
|
||||
const { session, keeper, store, ran, fire } = setup();
|
||||
await ran(result());
|
||||
expect((await store.get(9999))?.cards.every((c) => c.selected)).toBe(true);
|
||||
session.update(session.cards[1]!.cover.sha256, { selected: false });
|
||||
keeper.edited();
|
||||
expect((await store.get(9999))?.cards[1]!.selected).toBe(true);
|
||||
fire();
|
||||
await keeper.idle();
|
||||
expect((await store.get(9999))?.cards[1]!.selected).toBe(false);
|
||||
});
|
||||
|
||||
test("searching again first saves a pending edit, then carries it over", async () => {
|
||||
const { session, keeper, store, ran } = setup();
|
||||
const r = result();
|
||||
await ran(r);
|
||||
session.update(r.fresh[1]!.sha256, { selected: false });
|
||||
keeper.edited();
|
||||
await keeper.flush();
|
||||
await ran(r);
|
||||
expect(session.cards[1]!.selected).toBe(false);
|
||||
expect((await store.get(9999))?.cards[1]!.selected).toBe(false);
|
||||
});
|
||||
|
||||
test("a removed entry stays removed, even with an edit pending or after another edit", async () => {
|
||||
const { session, keeper, store, ran, fire } = setup();
|
||||
await ran(result());
|
||||
session.update(session.cards[0]!.cover.sha256, { note: "x" });
|
||||
keeper.edited();
|
||||
await keeper.forget(9999);
|
||||
fire();
|
||||
keeper.edited();
|
||||
fire();
|
||||
await keeper.idle();
|
||||
expect(await store.get(9999)).toBeNull();
|
||||
});
|
||||
|
||||
test("clear history stays cleared while the same series is on screen", async () => {
|
||||
const { keeper, store, ran, fire } = setup();
|
||||
await ran(result(1));
|
||||
await ran(result(2));
|
||||
await keeper.clear();
|
||||
keeper.edited();
|
||||
fire();
|
||||
await keeper.flush();
|
||||
await keeper.idle();
|
||||
expect(await store.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a new search tracks again after a removal", async () => {
|
||||
const { keeper, store, ran } = setup();
|
||||
await ran(result());
|
||||
await keeper.forget(9999);
|
||||
await ran(result());
|
||||
expect(await store.get(9999)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("opening an entry doesn't re-save the one on screen when nothing changed", async () => {
|
||||
const { keeper, store, ran } = setup();
|
||||
await ran(result(1));
|
||||
await Bun.sleep(5);
|
||||
await ran(result(2));
|
||||
const before = (await store.get(2))!.savedAt;
|
||||
await Bun.sleep(5);
|
||||
await keeper.open(1, Date.now());
|
||||
expect((await store.get(2))!.savedAt).toBe(before);
|
||||
expect((await store.list())[0]!.seriesId).toBe(2);
|
||||
});
|
||||
|
||||
test("a refused save is reported; a later good save clears the report", async () => {
|
||||
const real = memoryHistory();
|
||||
let refuse = true;
|
||||
const flaky: HistoryStore = { ...real, put: (s) => (refuse ? Promise.reject(new Error("QuotaExceededError")) : real.put(s)) };
|
||||
const { ran, errors } = setup(flaky);
|
||||
await ran(result());
|
||||
expect(errors.at(-1)).toMatch(/couldn't be saved/);
|
||||
refuse = false;
|
||||
await ran(result());
|
||||
expect(errors.at(-1)).toBeNull();
|
||||
});
|
||||
});
|
||||
+42
-1
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, setSystemTime, test } from "bun:test";
|
||||
import { HostError } from "../src/core/hosting/host";
|
||||
import { MangaBakaError } from "../src/core/mangabaka/client";
|
||||
import type { SubmissionImage } from "../src/core/mangabaka/types";
|
||||
@@ -186,3 +186,44 @@ describe("hosting is remembered on the cards", () => {
|
||||
expect(second.cards[0]!.hostedUrl).toBe("https://litter.catbox.moe/a.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hosted links that expire", () => {
|
||||
const T = Date.UTC(2026, 9, 15, 10);
|
||||
const MIN = 60_000;
|
||||
|
||||
test("a link reused from the rehost cache keeps its original expiry", async () => {
|
||||
const fake = new FakeTransport().on(LITTERBOX, () => text("https://litter.catbox.moe/a.jpg"));
|
||||
const s = new Session(deps(fake));
|
||||
s.load(result());
|
||||
try {
|
||||
setSystemTime(new Date(T));
|
||||
await s.rehost("litterbox", { litterboxTime: "1h" });
|
||||
setSystemTime(new Date(T + 20 * MIN));
|
||||
await s.rehost("litterbox", { litterboxTime: "1h" });
|
||||
expect(s.cards[0]!.hosting!.until).toBe(T + 60 * MIN);
|
||||
} finally {
|
||||
setSystemTime();
|
||||
}
|
||||
});
|
||||
|
||||
test("a restored link that has expired since is uploaded again, not reused", async () => {
|
||||
let n = 0;
|
||||
const fake = new FakeTransport().on(LITTERBOX, () => text(`https://litter.catbox.moe/${++n}.jpg`));
|
||||
const r = result();
|
||||
const first = new Session(deps(fake));
|
||||
first.load(r);
|
||||
try {
|
||||
setSystemTime(new Date(T));
|
||||
await first.rehost("litterbox", { litterboxTime: "1h" });
|
||||
setSystemTime(new Date(T + 55 * MIN));
|
||||
const second = new Session(deps(fake));
|
||||
second.restore(r, first.cards.map((c) => ({ ...c })), true, null);
|
||||
setSystemTime(new Date(T + 90 * MIN));
|
||||
await second.rehost("litterbox", { litterboxTime: "1h" });
|
||||
expect(second.cards[0]!.hostedUrl).toBe("https://litter.catbox.moe/2.jpg");
|
||||
expect(second.cards[0]!.hosting!.until).toBe(T + 150 * MIN);
|
||||
} finally {
|
||||
setSystemTime();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user