Files
mangaBookmark/userscript/test/logic.test.js
T
sulthan e2c054e7ce Covers render in the userscript panel, from a public route (#60) (#69)
Closes #60.

Spec: #55. Originating bug: #47. Architecture: `docs/adr/0007-backend-hosts-cover-bytes.md`. Neither #47 nor #55 is closed from here.

## What this branch does

The panel now renders Covers from the deployment's own origin, and both userscripts stop having an opinion about where a Cover lives.

**The public route was already in place.** `GET /covers/{address}` landed with #59 (`92eba07`) and is registered on the bare mux, outside `httpmw.Auth` and outside the web UI's Discord session — `backend/main.go:210-214`, handler `backend/internal/api/handlers.go:142-158`. It reads no cookie and no header, answers `404` for an address that was never stored (and for a row whose file has gone missing — recorded-but-gone is not-found, never a fabricated body), refuses anything that is not `^[0-9a-f]{64}$` *before* the value becomes a path, and sets `Cache-Control: public, max-age=604800, immutable`. Those four properties are asserted by `backend/cover_test.go:231-278`. This branch re-verified them rather than re-implementing them; the only backend line it touches is a comment.

**Both userscripts lose cover scraping entirely.** Every adapter's `cover:` field is gone, along with the two helpers that fed them: the manga script's `coverFromPage()` (the `img[alt]` DOM scan comix needed, because comix publishes no `og:image`) and the novel script's `metaName()` plus the now-callerless module-level `meta()`. Nothing under `userscript/` reads `og:image`, `meta[name=image]`, or `img[alt]` any more.

**Nothing sends a cover either.** `delete body.cover` sits in `apiPut` — `manga-bookmark.user.js:486`, `novel-bookmark.user.js:275` — which is the single chokepoint every write passes through (`pushBookmark`, the retry-queue flush, `toggleFavorite`, `toggleArchive`). It operates on the `Object.assign` copy, so the in-memory row keeps the cover it renders with. This matters beyond tidiness: a Reader upgrading from an older copy has `localStorage` rows carrying third-party scraped URLs, and without the strip those would ride back up on the next write. The handler discards the field regardless (`handlers.go:53-59`) — it is permanently inert, not pending removal.

**Failed loads get the designed empty state, not the broken-image glyph.** `onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" }))` on the cover `<img>` in both card renderers (`manga:1380-1390`, `novel:1134-1144`). The replacement is byte-identical to the existing no-cover branch on the very next line, so it picks up the `.cover.ph` styling already in the panel CSS — no new tokens, no new rule. `el()` routes any `on*` prop through `addEventListener`, so this is a listener, not an inline attribute string, and the swap is a `createElement` + DOM call with no markup parsing anywhere near it. This is the half of #47 that was visible on kagane.

**The deleted scraping's tests went with it**: the two comix cover cases, the `pageImages` and `namedMetas` fixtures, the `img[alt]` and `meta[name=...]` stub branches, the now-dead `querySelectorAll` stub member, and every stale `og:image` fixture and `p.cover` assertion across both suites. The export lists needed no change and that was checked, not assumed — `coverFromPage` and `metaName` were module-private on `origin/main` and no cover symbol ever appeared in `module.exports`.

Docs that described the deleted behaviour were corrected in the same breath, because leaving them would instruct the next agent to put the scraping back: `userscript/AGENTS.md` (adapter contract + the per-site notes for comix, kagane and novelfull), the README's adapter reference, and the userscript testing skill's stub table.

## Verification

- `go test -count=1 ./...` — green across all nine packages (`backend` 29.8s, `latest`, `store`, `session`, `token`, `userscript`, `web`).
- `node --check` clean on both userscripts; `node --test` on both logic suites — 46 tests, 46 pass.
- `gofmt -l` clean; `go build ./...` clean.
- The `onerror` swap is DOM behaviour and deliberately has no coverage in the Node harness — that harness stubs a browser precisely so it never needs a DOM, and #60 says not to invent coverage for it. It was instead exercised for real: the `el()` helper and the exact render expression were loaded into a headless Chromium with a deliberately unloadable `src`, and the resulting DOM was `<div class="cover ph"></div>`. Ad hoc, not committed.
- **Not done, needs you:** the on-device criterion — a comix Series bookmarked mid-chapter showing its Cover in the panel. That needs a real install against the deployment and is the one box left unticked on #60.

## Reviewed

Both `/code-review` axes ran against `cc0fa92`. Spec found no missed requirement and no scope creep; standards found the diff clean on the four areas it scrutinised (the `delete body.cover` placement, the `onerror` handler's DOM safety, comment quality, dead-code removal). Their combined findings — the dead `querySelectorAll` stub, the stale README and skill text, and the handler comment whose premise this change invalidates — are fixed in `8b58019`.

## Out of scope, deliberately

The kagane-specific cover proxy still exists and still carries its session gate (#63 deletes it). The poll's blank-Cover fill (#61) and browser-backed Sites joining the pipeline (#62) are untouched.

Reviewed-on: #69
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-10 08:52:57 +07:00

406 lines
16 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert");
// ============================================================
// Minimal browser stub
//
// manga-bookmark.user.js is a browser IIFE. At module scope it touches
// localStorage (loadCache, loadQueue, the key-migration IIFE) and, on its very
// last lines, document. Nothing else runs under test: document.body is left
// UNDEFINED, so the boot block takes its `else` branch and waits for a
// DOMContentLoaded that never fires. buildUI, init, and every network call stay
// dormant with no further stubbing.
// ============================================================
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
// location stub. The IIFE reads location.href during boot; set it to a dummy value.
globalThis.location = {
href: "about:blank",
hostname: "",
pathname: "/",
origin: "",
};
// og: meta tags the adapters read through meta(). Reassigned per test.
let metaTags = {};
// document.title. comix's SPA rewrites this on client routing but never
// og:title, so the comix adapter reads it instead. Reassigned per test.
let docTitle = "";
globalThis.document = {
querySelector(sel) {
const m = sel.match(/^meta\[property="([^"]+)"\]$/);
if (!m) return null;
const v = metaTags[m[1]];
return v == null ? null : { getAttribute: () => v };
},
addEventListener() {},
get title() {
return docTitle;
},
body: undefined,
};
const {
stripBuildHash,
comixSeriesId,
asura,
demonic,
comix,
kagane,
anchorsFromHTML,
statusOf,
kindOf,
} = require("../manga-bookmark.user.js");
// detect() reads only these four properties off location.
function loc(href) {
const u = new URL(href);
return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
}
// ============================================================
// stripBuildHash — Asura's slug suffix rotates on every redeploy, so it must
// not be part of the series identity. Must stay in sync with asuraBuildHash in
// backend/store.go.
// ============================================================
test("stripBuildHash removes a trailing 8-hex suffix", () => {
assert.equal(stripBuildHash("solo-leveling-059befe1"), "solo-leveling");
});
test("stripBuildHash leaves a slug with no suffix alone", () => {
assert.equal(stripBuildHash("solo-leveling"), "solo-leveling");
});
test("stripBuildHash ignores suffixes that are not exactly 8 hex chars", () => {
assert.equal(stripBuildHash("solo-leveling-abc1234"), "solo-leveling-abc1234");
assert.equal(stripBuildHash("solo-leveling-059befe1a"), "solo-leveling-059befe1a");
assert.equal(stripBuildHash("solo-leveling-059befeg1"), "solo-leveling-059befeg1");
});
// ============================================================
// Asura adapter
// ============================================================
test("asura.detect reads a series page, stripping the hash from the id only", () => {
metaTags = { "og:title": "Solo Leveling | Asura Scans" };
const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1"));
assert.equal(p.type, "series");
assert.equal(p.site, "asura");
assert.equal(p.seriesId, "solo-leveling");
// seriesUrl keeps the hash: navigation needs the current one (stale ones 302).
assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1");
assert.equal(p.title, "Solo Leveling");
assert.equal(p.chapterNum, null);
});
test("asura.detect reads a chapter page including a decimal number", () => {
metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans" };
const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5";
const p = asura.detect(loc(url));
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, "solo-leveling");
assert.equal(p.chapterNum, 12.5);
assert.equal(p.chapterLabel, "Chapter 12.5");
assert.equal(p.chapterUrl, url);
assert.equal(p.title, "Solo Leveling");
});
test("asura.detect returns other for non-series paths", () => {
metaTags = {};
assert.equal(asura.detect(loc("https://asurascans.com/")).type, "other");
assert.equal(asura.detect(loc("https://asurascans.com/bookmarks")).type, "other");
});
test("asura.latestChapterFromAnchors takes the highest and skips the First Chapter shortcut", () => {
const best = asura.latestChapterFromAnchors([
{ href: "/comics/solo-leveling-059befe1/chapter/1", text: "Chapter 1" },
{ href: "/comics/solo-leveling-059befe1/chapter/10", text: "Chapter 10" },
{ href: "/comics/solo-leveling-059befe1/chapter/2", text: "Chapter 2" },
// Points at a chapter but is not a list entry — the text check excludes it.
{ href: "/comics/solo-leveling-059befe1/chapter/1", text: "First Chapter" },
]);
assert.deepEqual(best, { num: 10, label: "Chapter 10" });
});
test("asura.latestChapterFromAnchors returns null when nothing matches", () => {
assert.equal(asura.latestChapterFromAnchors([{ href: "/about", text: "About" }]), null);
});
// ============================================================
// Demonic adapter
// ============================================================
test("demonic.detect reads a series page", () => {
metaTags = { "og:title": "The World After The Fall" };
const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall"));
assert.equal(p.type, "series");
assert.equal(p.site, "demonic");
assert.equal(p.seriesId, "the-world-after-the-fall");
assert.equal(p.title, "The World After The Fall");
});
test("demonic.detect reads a chapter page and strips the suffix from the title", () => {
metaTags = { "og:title": "The World After The Fall Chapter 3" };
const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1"));
assert.equal(p.type, "chapter");
assert.equal(p.chapterNum, 3);
assert.equal(p.chapterLabel, "Chapter 3");
assert.equal(p.seriesUrl, "https://demonicscans.org/manga/the-world-after-the-fall");
assert.equal(p.title, "The World After The Fall");
});
test("demonic percent-encoded slugs decode to the same id on /manga/ and /title/", () => {
metaTags = {};
const series = demonic.detect(loc("https://demonicscans.org/manga/i%2527m-the-max-level-newbie"));
const chapter = demonic.detect(loc("https://demonicscans.org/title/i%2527m-the-max-level-newbie/chapter/3/1"));
assert.equal(series.seriesId, chapter.seriesId);
assert.equal(series.seriesId, "i%27m-the-max-level-newbie");
});
test("demonic.latestChapterFromAnchors parses chaptered.php links, including &amp;", () => {
const best = demonic.latestChapterFromAnchors([
{ href: "chaptered.php?manga=5&chapter=3", text: "Chapter 3" },
{ href: "chaptered.php?manga=5&amp;chapter=12", text: "Chapter 12" },
{ href: "/manga/something", text: "Series" },
]);
assert.deepEqual(best, { num: 12, label: "Chapter 12" });
});
// ============================================================
// comix — the id prefix is the stable identity; the slug tail is a title
// rendering that changes when a series is renamed.
// ============================================================
test("comixSeriesId keeps only the prefix before the first dash", () => {
assert.equal(comixSeriesId("n8we-dungeons-and-crayons"), "n8we");
assert.equal(comixSeriesId("20xzd-the-baddest-villainess-is-back"), "20xzd");
});
test("comixSeriesId leaves a bare id untouched", () => {
assert.equal(comixSeriesId("n8we"), "n8we");
});
// comix is an SPA that rewrites document.title on client routing but leaves the
// server-rendered og:title untouched, so every test here pins og:title to a
// STALE value — the homepage title on first hop, the previous series after
// that. Captured live 2026-08-08.
const COMIX_STALE_HOME = "Comix - Read Comics online for free";
test("comix detects a series page", () => {
metaTags = { "og:title": COMIX_STALE_HOME };
docTitle = "Dungeons and Crayons";
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
assert.equal(p.type, "series");
assert.equal(p.site, "comix");
assert.equal(p.seriesId, "n8we");
assert.equal(p.title, "Dungeons and Crayons");
assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons");
assert.equal(p.chapterNum, null);
});
test("comix ignores a previous series' stale og:title", () => {
metaTags = { "og:title": "Full-Time Awakening" };
docTitle = "Dungeons and Crayons";
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
assert.equal(p.title, "Dungeons and Crayons");
});
test("comix detects a chapter page and strips the Ch. suffix from the title", () => {
metaTags = { "og:title": COMIX_STALE_HOME };
docTitle = "Dungeons and Crayons · Ch.80";
const p = comix.detect(
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80")
);
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, "n8we");
assert.equal(p.title, "Dungeons and Crayons");
assert.equal(p.chapterNum, 80);
assert.equal(p.chapterLabel, "Chapter 80");
assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons");
});
test("comix parses decimal chapter numbers", () => {
metaTags = {};
docTitle = "Dungeons and Crayons · Ch.80.5";
const p = comix.detect(
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80.5")
);
assert.equal(p.chapterNum, 80.5);
});
test("comix ignores unrelated paths", () => {
assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other");
});
test("comix takes the max chapter and ignores other series' links", () => {
const anchors = anchorsFromHTML(`
<a href="/title/n8we-dungeons-and-crayons/11123327-chapter-79">Chapter 79</a>
<a href="/title/n8we-dungeons-and-crayons/11139891-chapter-80">Chapter 80</a>
<a href="/title/n8we-dungeons-and-crayons/10794753-chapter-78">Chapter 78</a>
<a href="/title/qqwrm-full-time-awakening/99999999-chapter-999">Chapter 999</a>
`);
assert.deepEqual(comix.latestChapterFromAnchors(anchors, "n8we"), {
num: 80,
label: "Chapter 80",
});
});
test("comix latest returns null when no chapter links are present", () => {
assert.equal(comix.latestChapterFromAnchors(anchorsFromHTML("<a href='/browse'>x</a>"), "n8we"), null);
});
test("asura and demonic ignore the seriesId argument", () => {
const asuraAnchors = anchorsFromHTML(
`<a href="/comics/x-aabbccdd/chapter/12"><span>Chapter 12</span></a>`
);
assert.deepEqual(asura.latestChapterFromAnchors(asuraAnchors, "ignored"), {
num: 12,
label: "Chapter 12",
});
});
// ============================================================
// kagane — reader URLs carry uuids and no chapter number, so the number has to
// come out of og:title. When that fails, chapterNum is null and the existing
// progress logic records the current chapter rather than guessing.
// ============================================================
const KAGANE_SERIES = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b";
const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e";
test("kagane detects a series page", () => {
metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" };
const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES));
assert.equal(p.type, "series");
assert.equal(p.site, "kagane");
assert.equal(p.seriesId, KAGANE_SERIES);
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
});
test("kagane reads the chapter number out of og:title", () => {
metaTags = {
"og:title": "Infinite Decryption: The Strongest Level 0 - Chapter 41 - Episode 41",
};
const p = kagane.detect(
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
);
assert.equal(p.type, "chapter");
assert.equal(p.seriesId, KAGANE_SERIES);
assert.equal(p.title, "Infinite Decryption: The Strongest Level 0");
assert.equal(p.chapterNum, 41);
assert.equal(p.chapterLabel, "Chapter 41");
assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES);
});
// Volume-numbered series render the suffix as "- Volume <v> Chapter <n>" with
// no episode name, because the book carries volume_no and an empty title.
// Captured live 2026-08-08 from SP Baby.
test("kagane reads through a Volume-numbered chapter suffix", () => {
metaTags = { "og:title": "SP Baby - Volume 1 Chapter 1" };
const p = kagane.detect(
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
);
assert.equal(p.title, "SP Baby");
assert.equal(p.chapterNum, 1);
assert.equal(p.chapterLabel, "Chapter 1");
});
// A book with neither a volume nor an episode name ends the title right after
// the number, which the old trailing-\s regex could not match.
test("kagane reads a chapter suffix with no episode name", () => {
metaTags = { "og:title": "Some Series - Chapter 7.5" };
const p = kagane.detect(
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
);
assert.equal(p.title, "Some Series");
assert.equal(p.chapterNum, 7.5);
});
test("kagane yields a null chapterNum when og:title has no chapter", () => {
metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" };
const p = kagane.detect(
loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK)
);
assert.equal(p.type, "chapter");
assert.equal(p.chapterNum, null);
});
test("kagane ignores unrelated paths", () => {
assert.equal(kagane.detect(loc("https://kagane.to/search")).type, "other");
});
test("kagane anchor scanning is structurally impossible and returns null", () => {
const anchors = anchorsFromHTML(
`<a href="/series/${KAGANE_SERIES}/reader/${KAGANE_BOOK}">Chapter 41</a>`
);
assert.equal(kagane.latestChapterFromAnchors(anchors, KAGANE_SERIES), null);
});
test("kagane latestChapterFromApi takes the max chapter_no", async () => {
globalThis.fetch = async (url) => {
assert.equal(url, "/api/v2/series/" + KAGANE_SERIES);
return {
ok: true,
json: async () => ({
series_books: [
{ chapter_no: "1", title: "Episode 1" },
{ chapter_no: "41", title: "Episode 41" },
{ chapter_no: "40.5", title: "Episode 40.5" },
],
}),
};
};
assert.deepEqual(await kagane.latestChapterFromApi(KAGANE_SERIES), {
num: 41,
label: "Chapter 41",
});
});
test("kagane latestChapterFromApi returns null on a challenge or error", async () => {
globalThis.fetch = async () => ({ ok: false, status: 403 });
assert.equal(await kagane.latestChapterFromApi(KAGANE_SERIES), null);
});
// ============================================================
// Shared helpers
// ============================================================
test("anchorsFromHTML pulls href/text pairs and flattens nested tags", () => {
const out = anchorsFromHTML('<a href="/a">One</a><a class="x" href="/b"><span>Two</span> more</a>');
assert.equal(out.length, 2);
assert.deepEqual(out[0], { href: "/a", text: "One" });
assert.equal(out[1].href, "/b");
assert.equal(out[1].text.replace(/\s+/g, " ").trim(), "Two more");
});
test("statusOf defaults a missing status to reading", () => {
assert.equal(statusOf({}), "reading");
assert.equal(statusOf({ status: "" }), "reading");
assert.equal(statusOf({ status: "archived" }), "archived");
assert.equal(statusOf({ status: "finished" }), "finished");
});
// ============================================================
// kindOf — a row written before the kind column existed has none, and every
// one of those is manga.
// ============================================================
test("kindOf defaults a missing kind to manga", () => {
assert.equal(kindOf({}), "manga");
assert.equal(kindOf({ kind: "" }), "manga");
});
test("kindOf passes through an explicit kind", () => {
assert.equal(kindOf({ kind: "novel" }), "novel");
});