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:
Lucas WintherandClaude Opus 5.5 committed 2026-10-15 11:45:48 +02:00
1 parent 1cf31b26cf
commit 0d4a1a61fa
8 files changed
+362 -68

No files matched your search

+27 -3
View File
@@ -81,6 +81,8 @@ export class Session {
makeDefault = true;
private conflicts = 0;
private readonly hostedCache: Map<string, string>;
/** When each cached link expires (epoch ms; null = never). A cached link must not outlive its host. */
private readonly cacheUntil = new Map<string, number | null>();
constructor(private readonly deps: SessionDeps) {
this.hostedCache = deps.persistHostedUrls ? loadHosted(deps.store) : new Map();
@@ -115,7 +117,13 @@ export class Session {
this.makeDefault = makeDefault && result.suggestedDefault !== null;
this.input = input;
this.conflicts = 0;
for (const c of cards) if (c.hostedUrl && c.hosting?.key) this.hostedCache.set(c.hosting.key, c.hostedUrl);
const now = Date.now();
for (const c of cards) {
if (c.hostedUrl && c.hosting?.key && !expiresSoon(c.hosting.until, now)) {
this.hostedCache.set(c.hosting.key, c.hostedUrl);
this.cacheUntil.set(c.hosting.key, c.hosting.until);
}
}
}
update(sha256: string, patch: Partial<Pick<ReviewCard, "selected" | "type" | "note" | "contentRating">>): void {
@@ -164,6 +172,14 @@ export class Session {
/** Explicit user action only (spec §4.6). */
async rehost(hostId: HostId, options: HostOptions, onProgress?: (done: number, total: number) => void): Promise<RehostResult> {
const cards = this.selected();
const now = Date.now();
for (const [key, until] of this.cacheUntil) {
if (expiresSoon(until, now)) {
this.hostedCache.delete(key);
this.cacheUntil.delete(key);
}
}
const cachedBefore = new Set(this.hostedCache.keys());
const res = await rehostAll(
cards.map((c) => hostImage(c.cover)),
HOSTS[hostId],
@@ -171,10 +187,14 @@ export class Session {
this.hostedCache,
onProgress,
);
const until = linkExpiry(hostId, options, Date.now());
const fresh = linkExpiry(hostId, options, now);
for (const c of cards) {
c.hostedUrl = res.hosted.get(c.cover.sha256) ?? null;
c.hosting = c.hostedUrl ? { by: hostId, key: cacheKey(hostId, options, c.cover.sha256), until } : undefined;
const key = cacheKey(hostId, options, c.cover.sha256);
// A link reused from the cache keeps the expiry it was uploaded with.
const until = key && cachedBefore.has(key) ? (this.cacheUntil.get(key) ?? fresh) : fresh;
if (key && c.hostedUrl) this.cacheUntil.set(key, until);
c.hosting = c.hostedUrl ? { by: hostId, key, until } : undefined;
c.error = res.failures.find((f) => f.sha256 === c.cover.sha256)?.message ?? null;
}
if (this.deps.persistHostedUrls) saveHosted(this.deps.store, this.hostedCache);
@@ -229,6 +249,10 @@ export class Session {
}
}
/** Links this close to expiry are uploaded again rather than reused. */
const EXPIRY_MARGIN_MS = 5 * 60_000;
const expiresSoon = (until: number | null | undefined, now: number): boolean => until != null && until <= now + EXPIRY_MARGIN_MS;
/** Only catbox links are kept across runs: litterbox links die within 72 hours. */
function loadHosted(store: KeyValueStore): Map<string, string> {
try {
+34 -60
View File
@@ -3,10 +3,10 @@ import { HOSTS } from "../core/hosting/registry";
import { coverFormUrl } from "../core/mangabaka/client";
import { CONTENT_RATINGS, GRANULARITIES, type ContentRating, type Granularity } from "../core/model";
import type { RunResult } from "../core/pipeline";
import { carryEdits, fromSaved, saveAndPrune, toSaved, type HistoryStore, type Marks } from "../core/saved";
import type { HistoryStore } from "../core/saved";
import { loadToken, saveToken, type KeyValueStore, type PreparedSubmission, type Session } from "../core/session";
import { SOURCES } from "../core/sources/registry";
import { zipStore } from "../core/zip";
import { HistoryKeeper } from "./history-keeper";
import { renderRecent } from "./recent";
import { renderCards, renderPresent, renderPreview } from "./render";
import { saveFile } from "./save-file";
@@ -24,7 +24,7 @@ export interface AppOptions {
catboxNeedsServer: boolean;
/** Userscript: the MangaBaka series page it runs on. */
seriesLink?: string;
/** The page's history of searches (spec 002 §3); the userscript has none. */
/** The page's history of searches (spec 002 §3): null when the browser refused storage; the userscript has none. */
history?: HistoryStore | null;
}
@@ -105,11 +105,15 @@ export function mountApp(o: AppOptions): void {
let running: AbortController | null = null;
let stopHistory: AbortController | null = null;
let prepared: PreparedSubmission | null = null;
/** The history entry on screen: what was done with it, and when its search ran. */
let marks: Marks = {};
let foundAt = "";
let currentId: number | null = null;
let saveTimer: ReturnType<typeof setTimeout> | undefined;
const keeper = o.history
? new HistoryKeeper(o.history, o.session, {
rating: () => ui.ratingAll.value as ContentRating,
onStatus: (error) => {
status(ui.recentStatus, error ?? "", error !== null);
void refreshRecent();
},
})
: null;
const fill = (select: HTMLSelectElement, values: readonly (string | number)[], label: (v: string) => string, selected: string) =>
select.replaceChildren(...values.map(String).map((v) => new Option(label(v), v, false, v === selected)));
@@ -181,7 +185,7 @@ export function mountApp(o: AppOptions): void {
ui.confirm.hidden = true;
ui.previewDiff.replaceChildren();
draw();
scheduleSave();
keeper?.edited();
}
async function refreshRecent(): Promise<void> {
@@ -189,33 +193,7 @@ export function mountApp(o: AppOptions): void {
const items = await o.history.list().catch(() => []);
ui.recent.hidden = items.length === 0;
ui.recentShelfWrap.hidden = items.length === 0;
renderRecent(ui.recentList, ui.recentShelf, items, currentId, new Date(), { open: (id) => void open(id), remove: (id) => void forget(id) });
}
/** Spec 002 §3.1: the entry is rewritten after the search, each edit, rehosting, a download and filing. */
function save(): void {
clearTimeout(saveTimer);
const r = o.session.result;
const input = o.session.input;
if (!o.history || !r || !input) return;
const entry = toSaved({
result: r, cards: o.session.cards, makeDefault: o.session.makeDefault, input,
rating: ui.ratingAll.value as ContentRating, marks, foundAt, now: new Date(),
});
saveAndPrune(o.history, entry)
.then(() => status(ui.recentStatus, ""))
.catch(() => status(ui.recentStatus, "This search couldn't be saved: the browser refused the storage.", true))
.finally(() => void refreshRecent());
}
function scheduleSave(): void {
clearTimeout(saveTimer);
if (o.history) saveTimer = setTimeout(save, 500);
}
function mark(patch: Marks): void {
marks = { ...marks, ...patch };
save();
renderRecent(ui.recentList, ui.recentShelf, items, keeper?.currentId ?? null, new Date(), { open: (id) => void open(id), remove: (id) => void forget(id) });
}
/** Shows a finished search: plates, notes, the series head and the shelf. */
@@ -252,22 +230,15 @@ export function mountApp(o: AppOptions): void {
}
async function open(seriesId: number): Promise<void> {
if (!o.history || running) return;
save();
if (!keeper || running) return;
try {
const entry = await o.history.get(seriesId);
if (!entry) throw new Error("That entry is no longer in the history.");
const back = fromSaved(entry, SOURCES, Date.now());
o.session.restore(back.result, back.cards, back.makeDefault, back.input);
const back = await keeper.open(seriesId, Date.now());
resetReview();
if (!ui.mbLink.readOnly) ui.mbLink.value = back.input.mangabakaLink;
ui.sourceLink.value = back.input.sourceLink;
ui.historyOn.checked = back.input.history !== null;
if (back.input.history !== null) ui.historyEvery.value = String(back.input.history);
ui.ratingAll.value = back.rating;
marks = back.marks;
foundAt = back.foundAt;
currentId = seriesId;
for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
show(back.result, back.input.history === null);
ui.foundWhen.textContent = `Found ${relativeTime(back.foundAt)}.`;
@@ -280,9 +251,7 @@ export function mountApp(o: AppOptions): void {
}
async function forget(seriesId: number): Promise<void> {
if (!o.history) return;
await o.history.remove(seriesId).catch(() => undefined);
if (currentId === seriesId) currentId = null;
await keeper?.forget(seriesId);
void refreshRecent();
}
@@ -309,7 +278,10 @@ export function mountApp(o: AppOptions): void {
}
async function run(): Promise<void> {
if (running) return;
running = new AbortController();
// An edit still waiting to be saved must reach the history before this search reads it back.
await keeper?.flush();
stopHistory = new AbortController();
prepared = null;
ui.run.disabled = true;
@@ -347,14 +319,9 @@ export function mountApp(o: AppOptions): void {
ui.ratingAll.value as ContentRating,
);
// Searching a series again keeps what the user did with it (spec 002 §3.3).
const previous = o.history ? await o.history.get(r.seriesId).catch(() => null) : null;
if (previous) carryEdits(previous.cards, o.session.cards, Date.now());
marks = previous?.marks ?? {};
foundAt = new Date().toISOString();
currentId = r.seriesId;
await keeper?.searched(r, Date.now());
show(r, !ui.historyOn.checked);
status(ui.stage, `Found ${r.fresh.length} new cover(s); ${r.present.length} already on MangaBaka.`);
save();
} catch (err) {
if (running?.signal.aborted) {
markStage(currentStage, "reset");
@@ -415,7 +382,7 @@ export function mountApp(o: AppOptions): void {
if (res.allFailed) status(ui.rehostStatus, `Every upload failed: ${res.failures[0]?.message ?? "unknown error"}`, true);
else {
const done = host === "direct" ? `${res.hosted.size} ready with their source URLs (not rehosted)` : `${res.hosted.size} rehosted`;
marks = { ...marks, rehosted: { at: new Date().toISOString(), host } };
keeper?.mark({ rehosted: { at: new Date().toISOString(), host } });
status(ui.rehostStatus, `${done}${res.failures.length ? `, ${res.failures.length} failed (see the covers)` : ""}.`);
}
} catch (err) {
@@ -430,7 +397,7 @@ export function mountApp(o: AppOptions): void {
const bundle = o.session.bundle();
saveFile(zipStore(bundle.files), bundle.zipName, "application/zip", ui.download);
status(ui.manualStatus, `Saved ${bundle.zipName} (${bundle.files.length - 1} covers and covers.txt).`);
mark({ downloaded: new Date().toISOString() });
keeper?.mark({ downloaded: new Date().toISOString() });
} catch (err) {
status(ui.manualStatus, errorText(err), true);
}
@@ -473,7 +440,7 @@ export function mountApp(o: AppOptions): void {
const outcome = await o.session.submit(t, prepared, host, hostOptions());
if (outcome.status === "saved") {
status(ui.submitStatus, `Filed edit request #${outcome.result.submission_id} (${outcome.result.status}). A MangaBaka moderator will review it.`);
mark({ filed: { at: new Date().toISOString(), submissionId: outcome.result.submission_id } });
keeper?.mark({ filed: { at: new Date().toISOString(), submissionId: outcome.result.submission_id } });
prepared = null;
ui.confirm.hidden = true;
} else {
@@ -491,12 +458,19 @@ export function mountApp(o: AppOptions): void {
ui.searchAgain.addEventListener("click", () => void run());
ui.recentClear.addEventListener("click", async () => {
if (!o.history || !confirm("Clear the whole history? The saved covers are deleted from this browser.")) return;
await o.history.clear().catch(() => undefined);
currentId = null;
if (!keeper || !confirm("Clear the whole history? The saved covers are deleted from this browser.")) return;
await keeper.clear();
void refreshRecent();
});
// Edits made just before the tab closes or is hidden still reach the history.
if (keeper) {
addEventListener("pagehide", () => void keeper.flush());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") void keeper.flush();
});
}
drawHosts();
if (o.history === null) status(ui.recentStatus, "History is off: this browser doesn't let the page store data.");
void refreshRecent();
}
+123
View File
@@ -0,0 +1,123 @@
import type { ContentRating } from "../core/model";
import type { RunResult } from "../core/pipeline";
import { carryEdits, fromSaved, saveAndPrune, toSaved, type HistoryStore, type Marks } from "../core/saved";
import type { Session } from "../core/session";
import { SOURCES } from "../core/sources/registry";
// Spec 002 §3: when the series on screen is written to the history. No DOM, so it is tested directly.
export interface KeeperOptions {
/** The rating chosen for new covers, stored with the inputs. */
rating(): ContentRating;
/** null once saving works (again), else a message for the user. */
onStatus(error: string | null): void;
schedule?(fn: () => void, ms: number): unknown;
cancel?(handle: unknown): void;
delayMs?: number;
}
export const SAVE_REFUSED = "This search couldn't be saved: the browser refused the storage.";
export class HistoryKeeper {
marks: Marks = {};
foundAt = "";
currentId: number | null = null;
/** False after the entry on screen was removed or the history cleared, until the next search or open. */
private tracked = false;
private pending = false;
private timer: unknown;
private chain: Promise<void> = Promise.resolve();
private readonly schedule: (fn: () => void, ms: number) => unknown;
private readonly cancel: (handle: unknown) => void;
constructor(private readonly store: HistoryStore, private readonly session: Session, private readonly opts: KeeperOptions) {
this.schedule = opts.schedule ?? ((fn, ms) => setTimeout(fn, ms));
this.cancel = opts.cancel ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
}
/** After a search: keep what the user did with this series before (spec 002 §3.3), then save at once. */
async searched(r: RunResult, now: number): Promise<void> {
this.stopTimer();
const previous = await this.store.get(r.seriesId).catch(() => null);
if (previous) carryEdits(previous.cards, this.session.cards, now);
this.marks = previous?.marks ?? {};
this.foundAt = new Date(now).toISOString();
this.currentId = r.seriesId;
this.tracked = true;
await this.save();
}
/** Reopens an entry into the session. Saves the one on screen first only if it has unsaved edits. */
async open(seriesId: number, now: number) {
await this.flush();
const entry = await this.store.get(seriesId);
if (!entry) throw new Error("That entry is no longer in the history.");
const back = fromSaved(entry, SOURCES, now);
this.session.restore(back.result, back.cards, back.makeDefault, back.input);
this.marks = back.marks;
this.foundAt = back.foundAt;
this.currentId = seriesId;
this.tracked = true;
return back;
}
edited(): void {
if (!this.tracked) return;
this.stopTimer();
this.pending = true;
this.timer = this.schedule(() => void this.save(), this.opts.delayMs ?? 500);
}
mark(patch: Marks): void {
this.marks = { ...this.marks, ...patch };
if (this.tracked) void this.save();
}
/** Saves now if an edit is waiting (before a new search, opening another entry, or leaving the page). */
async flush(): Promise<void> {
if (this.pending) await this.save();
else await this.idle();
}
idle(): Promise<void> {
return this.chain;
}
async forget(seriesId: number): Promise<void> {
if (this.currentId === seriesId) this.untrack();
await this.idle();
await this.store.remove(seriesId).catch(() => undefined);
}
async clear(): Promise<void> {
this.untrack();
await this.idle();
await this.store.clear().catch(() => undefined);
}
async save(): Promise<void> {
this.stopTimer();
const r = this.session.result;
const input = this.session.input;
if (!this.tracked || !r || !input) return;
const entry = toSaved({
result: r, cards: this.session.cards, makeDefault: this.session.makeDefault, input,
rating: this.opts.rating(), marks: this.marks, foundAt: this.foundAt, now: new Date(),
});
this.chain = this.chain
.then(() => saveAndPrune(this.store, entry))
.then(() => this.opts.onStatus(null), () => this.opts.onStatus(SAVE_REFUSED));
await this.chain;
}
private untrack(): void {
this.stopTimer();
this.tracked = false;
}
private stopTimer(): void {
if (this.timer !== undefined) this.cancel(this.timer);
this.timer = undefined;
this.pending = false;
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ function shelfBook(s: SearchSummary, now: Date, h: RecentHandlers): HTMLButtonEl
el("span", { className: "mbseed-recent-jacket" }, thumb(s)),
el("span", { className: "mbseed-recent-band" },
el("span", { className: "mbseed-recent-title", textContent: s.title }),
el("span", { className: "mbseed-recent-meta", textContent: `${relativeTime(s.savedAt, now)} · ${s.fresh} new` })));
el("span", { className: "mbseed-recent-meta", textContent: `${relativeTime(s.savedAt, now)} · ${s.fresh}\u00a0new` })));
book.addEventListener("click", () => h.open(s.seriesId));
return book;
}
+4 -2
View File
@@ -255,7 +255,9 @@
.mbseed-app .mbseed-recent-remove:hover { background: var(--mb-ink); color: var(--mb-page); }
/* The list scrolls, so an outside ring would be clipped: draw it inside the row. */
.mbseed-app .mbseed-recent-open:focus-visible, .mbseed-app .mbseed-recent-remove:focus-visible { outline-offset: -3px; }
.mbseed-app .mbseed-recent .mbseed-line:empty { display: none; }
.mbseed-app .mbseed-recent-status { font-size: 12.5px; }
.mbseed-app .mbseed-recent-status:not(.is-error) { color: var(--mb-ink-2); }
.mbseed-app .mbseed-recent-status:empty { display: none; }
.mbseed-app .mbseed-recent > .is-quiet { justify-self: start; width: auto; margin-left: -5.5px; font-size: 12.5px; color: var(--mb-ink-2); }
/* ---------- empty state ---------- */
@@ -284,7 +286,7 @@
background: var(--mb-slip); border: 1px solid var(--mb-line); border-top: 3px solid var(--mb-ink);
}
.mbseed-app .mbseed-recent-band .mbseed-recent-title { font-size: 12px; }
.mbseed-app .mbseed-recent-band .mbseed-recent-meta { color: var(--mb-ink-2); }
.mbseed-app .mbseed-recent-band .mbseed-recent-meta { color: var(--mb-ink-2); white-space: normal; text-overflow: clip; }
/* ---------- review head ---------- */
.mbseed-app .mbseed-review-head {
+1 -1
View File
@@ -40,10 +40,10 @@ export const APP_HTML = /* html */ `
<ul id="warnings" class="mbseed-warnings"></ul>
</section>
<p id="recent-status" class="mbseed-line mbseed-recent-status" aria-live="polite"></p>
<section id="recent" class="mbseed-recent" hidden>
<h3 class="mbseed-cap">History</h3>
<ol id="recent-list" class="mbseed-recent-list"></ol>
<p id="recent-status" class="mbseed-line" aria-live="polite"></p>
<button id="recent-clear" class="mbseed-button is-quiet" type="button">Clear history</button>
</section>
<a class="mbseed-link mbseed-install" href="/mbseed.user.js">Install the userscript</a>
+130
View File
@@ -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
View File
@@ -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();
}
});
});