diff --git a/src/core/session.ts b/src/core/session.ts index c64a4bc..a6ed2c5 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -81,6 +81,8 @@ export class Session { makeDefault = true; private conflicts = 0; private readonly hostedCache: Map; + /** When each cached link expires (epoch ms; null = never). A cached link must not outlive its host. */ + private readonly cacheUntil = new Map(); 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>): 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 { 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 { try { diff --git a/src/web/app.ts b/src/web/app.ts index fa0e0ee..52f2dd2 100644 --- a/src/web/app.ts +++ b/src/web/app.ts @@ -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 | 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 { @@ -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 { - 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 { - 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 { + 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(); } diff --git a/src/web/history-keeper.ts b/src/web/history-keeper.ts new file mode 100644 index 0000000..6449698 --- /dev/null +++ b/src/web/history-keeper.ts @@ -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 = 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)); + } + + /** 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 { + 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 { + if (this.pending) await this.save(); + else await this.idle(); + } + + idle(): Promise { + return this.chain; + } + + async forget(seriesId: number): Promise { + if (this.currentId === seriesId) this.untrack(); + await this.idle(); + await this.store.remove(seriesId).catch(() => undefined); + } + + async clear(): Promise { + this.untrack(); + await this.idle(); + await this.store.clear().catch(() => undefined); + } + + async save(): Promise { + 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; + } +} diff --git a/src/web/recent.ts b/src/web/recent.ts index c2d81d6..9ed75e8 100644 --- a/src/web/recent.ts +++ b/src/web/recent.ts @@ -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; } diff --git a/src/web/styles.css b/src/web/styles.css index 412584a..7bc8667 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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 { diff --git a/src/web/template.ts b/src/web/template.ts index 741de4c..4fdb760 100644 --- a/src/web/template.ts +++ b/src/web/template.ts @@ -40,10 +40,10 @@ export const APP_HTML = /* html */ `
    +

    Install the userscript diff --git a/test/history-keeper.test.ts b/test/history-keeper.test.ts new file mode 100644 index 0000000..604a28d --- /dev/null +++ b/test/history-keeper.test.ts @@ -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(); + }); +}); diff --git a/test/session.test.ts b/test/session.test.ts index 6981953..acc35de 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -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(); + } + }); +});