Files
mangaBookmark/backend/internal/latest/sites_test.go
sulthan ddbd57070d Poll comix.to through the browser sidecar (#98) (#105)
Closes #98.

comix.to began answering plain-TLS fetches with a Cloudflare JavaScript
challenge on 2026-08-12, so every poll got a 403 interstitial. Its cover host
`static.comix.to` is gated the same way. comix therefore joins kagane and
novelfull as a browser-backed Site.

## What changed

- **Registry** (`internal/latest/sites.go`): comix gains a `Browser` entry —
  `comixRead`, `Done: body != "" && !isInterstitial(body)`, `Fallback: false`.
  Skip-when-no-browser falls out of the existing routing; no site-string compare
  was added anywhere.
- **Read shape** (`internal/latest/browser.go`): an in-tab `fetch()` of the
  Series URL, not a DOM render. comix is an SPA — rendering it costs ~65
  requests for the same server-rendered HTML one fetch returns (24.5 KB,
  ~480 ms measured). `comixSeriesPageURL` pins scheme + host + `/title/<slug>`
  and rebuilds the address, so a client-supplied `series_url` cannot aim the
  browser anywhere else.
- **Cover bytes**: `comixImageURLRe` pins `https://static.comix.to/<path>.<ext>`;
  `BrowserFetcher.Image` now gates on `browserOnlyCoverURL` rather than a
  kagane-only regex, so both Sites' image URLs route through the one path.
  Bytes come from direct navigation, not a page-context fetch — comix's Series
  page sets `cross-origin-embedder-policy: require-corp`, which fails one.
- **Parsers and stored Series identity: untouched.** The in-tab body is the same
  server-rendered HTML the existing fixtures were cut from.

## Verification

- `go test ./...` green (needs Docker).
- New seam tests: comix routes to the browser when one is configured, and is
  not fetched at all when none is (`TestComixUsesBrowserFetcher`,
  `TestComixSkippedWhenNoBrowserFetcher`); URL-pin and cover-gate table tests.
- Live proof against the real browser unit, `TestSmokeComix` (env-gated):
  page 24793 bytes in one in-tab fetch, chapter 53, cover accepted by the pin,
  26862 bytes of `image/jpg` retrieved.
- Two-axis review run; findings were stale comments on `BrowserFetcher`, `Get`
  and the `Fallback` field, fixed in f000cc7.

Docs updated: root `AGENTS.md` (constraint + smoke command, including the note
that this dev machine's ISP DNS-hijacks `comix.to`), `backend/AGENTS.md`
(poller, cover pipeline, `BROWSER_WS_URL`), `REDEPLOY.md` §8 degrade note.

Reviewed-on: #105
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-16 12:13:59 +07:00

476 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package latest
import (
"strings"
"testing"
)
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
// fetched 2026-07-26. The first anchor is the "First Chapter" shortcut: it is a
// real chapter link with no "Chapter N" text, and it must not be mistaken for
// the latest just because it parses.
const asuraSeriesFixture = `
<a href="/comics/chronicles-of-the-demon-faction-f886a8af/chapter/1" class="py-3 rounded-md bg-[#E8E8E8]"><svg class="w-4 h-4"></svg>First Chapter</a>
<a href="/comics/chronicles-of-the-demon-faction-f886a8af/chapter/179" data-astro-prefetch="hover" class="group flex"><span class="font-medium">Chapter 179</span></a>
<a href="/comics/chronicles-of-the-demon-faction-f886a8af/chapter/181" data-astro-prefetch="hover" class="group flex"><span class="font-medium">Chapter 181</span></a>
<a href="/comics/chronicles-of-the-demon-faction-f886a8af/chapter/180" data-astro-prefetch="hover" class="group flex"><span class="font-medium">Chapter 180</span></a>
`
// A chapter link belonging to a different series, of the kind a "you might also
// like" strip would introduce. Slug scoping must exclude it.
const asuraCrossSeriesFixture = asuraSeriesFixture + `
<a href="/comics/some-other-series-aabbccdd/chapter/999" class="group flex"><span>Chapter 999</span></a>
`
// Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer fetched
// 2026-07-26. Note the raw "&", the doubled space after <a, and the decimal
// chapters, all as they appear live.
const demonicSeriesFixture = `
<a href="/chaptered.php?manga=11799&chapter=0.5" class="chplinks" title="Catastrophic Necromancer 0.5">Chapter 0.5</a>
<a href="/chaptered.php?manga=11799&chapter=294" class="chplinks" title="Catastrophic Necromancer 294">Chapter 294</a>
<a href="/chaptered.php?manga=11799&amp;chapter=296" class="chplinks" title="Catastrophic Necromancer 296">Chapter 296</a>
<a href="/chaptered.php?manga=11799&chapter=295" class="chplinks" title="Catastrophic Necromancer 295">Chapter 295</a>
`
// What Cloudflare serves instead of the page when an IP's bot score flips.
const challengeFixture = `<!DOCTYPE html><html><head><title>Just a moment...</title>
<script src="/cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1"></script></head>
<body><div id="challenge-running">Checking your browser</div></body></html>`
// Trimmed from the server-rendered HTML of
// https://comix.to/title/n8we-dungeons-and-crayons fetched 2026-08-03. comix is
// an SPA: the page ships a JSON state blob rather than a list of chapter
// anchors, and latestChapterUrl is where the newest chapter actually lives.
//
// Still the right fixture after comix moved behind the challenge (#98): the
// browser read is an in-tab fetch of the Series URL, so the body a poll parses
// is this same server-rendered HTML, not a rendered DOM. Confirmed against a
// live cleared tab 2026-08-16 (TestSmokeComix): the in-tab fetch returned
// 24793 bytes of server-rendered HTML that these same parses read a chapter
// and a cover out of.
const comixSeriesFixture = `
{"firstChapterUrl":"/title/n8we-dungeons-and-crayons/5038739-chapter-1","latestChapterUrl":"/title/n8we-dungeons-and-crayons/11139891-chapter-80"},
{""manga","recommended","n8we",1]":{"items":[{"latestChapterUrl":"/title/qqwrm-full-time-awakening/99999999-chapter-999"}]}
`
// The kagane branch is fed by the browser fetcher, so the body is API JSON, not
// HTML. Trimmed from GET /api/v2/series/<uuid> on 2026-08-03.
const kaganeAPIFixture = `
{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption",
"series_books":[{"book_id":"a","title":"Episode 1","chapter_no":"1","sort_no":1},
{"book_id":"b","title":"Episode 41","chapter_no":"41","sort_no":41},
{"book_id":"c","title":"Episode 40.5","chapter_no":"40.5","sort_no":40}]}
`
// Trimmed from https://novelfull.com/reverend-insanity.html fetched 2026-08-05.
// The page carries a newest-first "latest chapters" widget above an
// oldest-first paginated list, so the newest anchor is deliberately NOT last —
// only a maximum finds it. The final anchor belongs to another series and must
// be excluded by slug scoping.
const novelfullSeriesFixture = `
<div class="l-chapters">
<a href="/reverend-insanity/chapter-2334-fang-yuan-and-giant-sun.html">Chapter 2334</a>
<a href="/reverend-insanity/chapter-2333-three-venerables.html">Chapter 2333</a>
</div>
<ul class="list-chapter">
<li><a href="/reverend-insanity/chapter-1.html">Chapter 1</a></li>
<li><a href="/reverend-insanity/chapter-2.html">Chapter 2</a></li>
</ul>
<a href="/release-that-witch/chapter-9999.html">Chapter 9999</a>
`
// Trimmed from
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
// fetched 2026-08-11, whole document (~307 KB decoded; the wire body is ~32 KB
// zstd-compressed). This
// novel publishes its chapters under two Chapter Slugs: 1–99 at
// …-not-chapter-<n>/ and 100–423 at …-not-them-all-chapter-<n>/, and the page
// lists them newest-first, so the …-not-them-all anchors precede the …-not
// anchors. Every anchor through the comment-thread marker is verbatim page
// text (the site renders this novel's chapter titles as "[ ... words ]"). The
// comment block after the marker is the real wpdiscuz comment #wpd-comm-358_0
// from https://lightnovelworld.net/novel/the-sword-illuminates-the-great-wilderness/
// — the pinned page serves zero comments — with its share/link/vote/reply
// boilerplate trimmed. The comment's body carried no link, so the bare <a
// href> to https://lightnovelworld.net/overgeared-chapter-2059/ inside
// wpd-comment-text is the one composed element; that URL is a real chapter of
// a real different novel (overgeared; fetched, HTTP 200).
const lnwSeriesFixture = `
<li data-ID="102741">
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-404/">
<div class="epl-num">Vol. 1 Ch. 404</div>
<div class="epl-title">[ ... words ]</div>
<div class="epl-date">April 12, 2026</div>
</a>
</li>
<li data-ID="102780">
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-423/">
<div class="epl-num">Vol. 1 Ch. 423</div>
<div class="epl-title">[ ... words ]</div>
<div class="epl-date">April 7, 2026</div>
</a>
</li>
<li data-ID="102527">
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-300/">
<div class="epl-num">Vol. 1 Ch. 300</div>
<div class="epl-title">[ ... words ]</div>
<div class="epl-date">April 4, 2026</div>
</a>
</li>
<li data-ID="102325">
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-200/">
<div class="epl-num">Vol. 1 Ch. 200</div>
<div class="epl-title">[ ... words ]</div>
<div class="epl-date">March 29, 2026</div>
</a>
</li>
<li data-ID="102121">
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-100/">
<div class="epl-num">Vol. 1 Ch. 100</div>
<div class="epl-title">[ ... words ]</div>
<div class="epl-date">March 22, 2026</div>
</a>
</li>
<li data-ID="26014">
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-99/">
<div class="epl-num">Vol. 1 Ch. 99</div>
<div class="epl-title">Chapter 99</div>
<div class="epl-date">November 5, 2025</div>
</a>
</li>
<li data-ID="25916">
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-50/">
<div class="epl-num">Vol. 1 Ch. 50</div>
<div class="epl-title">Chapter 50</div>
<div class="epl-date">October 29, 2025</div>
</a>
</li>
<li class='tseplsfrst' data-ID="25818">
<a href="https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-1/">
<div class="epl-num">Vol. 1 Ch. 1</div>
<div class="epl-title">Chapter 01</div>
<div class="epl-date">October 11, 2025</div>
</a>
</li>
<div id="wpd-threads" class="wpd-thread-wrapper">
<div class="wpd-thread-list">
<div id='wpd-comm-358_0' class='comment byuser comment-author-jimbear even thread-even depth-1 wpd-comment wpd_comment_level-1'><div class="wpd-comment-wrap wpd-blog-user wpd-blog-subscriber">
<div class="wpd-comment-left ">
<div class="wpd-avatar ">
<img alt='hasbi asy' src='https://secure.gravatar.com/avatar/3c1792cb31cab842f90e7c463f0948e98536cf938aebbd2f678f937ca60fb799?s=64&#038;d=mm&#038;r=g' srcset='https://secure.gravatar.com/avatar/3c1792cb31cab842f90e7c463f0948e98536cf938aebbd2f678f937ca60fb799?s=128&#038;d=mm&#038;r=g 2x' class='avatar avatar-64 photo' height='64' width='64' decoding='async'/>
</div>
<div class="wpd-comment-label" wpd-tooltip="Member" wpd-tooltip-position="right">
<span>Member</span>
</div>
</div>
<div id="comment-358" class="wpd-comment-right">
<div class="wpd-comment-header">
<div class="wpd-comment-author ">
hasbi asy
</div>
<div class="wpd-comment-date" title="July 9, 2026 1:44 am">
<i class='far fa-clock' aria-hidden='true'></i>
1 month ago
</div>
</div>
<div class="wpd-comment-text">
<p>where&#8217;s everyone</p>
<a href="https://lightnovelworld.net/overgeared-chapter-2059/">https://lightnovelworld.net/overgeared-chapter-2059/</a>
</div>
</div>
</div>
<div id='wpdiscuz_form_anchor-358_0'></div>
</div>
</div>
`
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
// (redirected to ...-00dcbf97) on 2026-08-10.
const asuraCoverFixture = `<meta property="og:image" content="https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp">`
// Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer on 2026-08-10.
// The source publishes the raw space in this URL.
const demonicCoverFixture = `<meta property="og:image" content="https://readermc.org/images/thumbnails/Catastrophic Necromancer.webp">`
// Trimmed from https://comix.to/title/n8we-dungeons-and-crayons on 2026-08-10.
// The state includes a recommended poster before the target detail object and
// nested IDs inside that object; no og:image is present.
const comixCoverFixture = `<script type="application/json" id="initial-data">{"queries":{"[\"manga\",\"recommended\",\"n8we\",1]":{"poster":{"medium":"https://static.comix.to/recommended@280.jpg","large":"https://static.comix.to/recommended.jpg"}},"[\"manga\",\"detail\",\"n8we\"]":{"chapters":[{"hid":"nested"}],"poster":{"medium":"https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg","large":"https://static.comix.to/039d/i/1/34/6a6742bf15736.jpg"}}}}</script>`
// Trimmed from GET https://kagane.to/api/v2/series/019fe11a-8670-7cf3-8343-0b02057d3787 on 2026-08-10.
const kaganeCoverFixture = `{"series_covers":[{"cover_id":"019fe11a-84d1-714b-9cf4-2827f277f3c0","language":"en","volume_number":"1","chapter_number":null,"note":null,"image_id":"019fe11a-84c3-7fc3-a84b-88787374b617"}]}`
// Trimmed from https://novelfull.com/reverend-insanity.html on 2026-08-10.
const novelfullCoverFixture = `<meta name="image" content="https://novelfull.com/uploads/webp/novel/reverend-insanity-82661d911a.webp">`
// Trimmed from
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
// on 2026-08-11.
const lnwCoverFixture = `<meta property="og:image" content="https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg" />`
func TestCoverFrom(t *testing.T) {
const comixURL = "https://comix.to/title/n8we-dungeons-and-crayons"
tests := []struct {
name string
site string
seriesURL string
body string
wantOK bool
wantCover string
}{
{
name: "asura uses published metadata URL",
site: "asura", body: asuraCoverFixture, wantOK: true,
wantCover: "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp",
},
{
name: "demonic escapes raw spaces",
site: "demonic", body: demonicCoverFixture, wantOK: true,
wantCover: "https://readermc.org/images/thumbnails/Catastrophic%20Necromancer.webp",
},
{
name: "comix takes target medium poster",
site: "comix", seriesURL: comixURL, body: comixCoverFixture, wantOK: true,
wantCover: "https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg",
},
{
name: "kagane reads API cover image ID",
site: "kagane", body: kaganeCoverFixture, wantOK: true,
wantCover: "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
},
{
name: "novelfull reads image metadata",
site: "novelfull", body: novelfullCoverFixture, wantOK: true,
wantCover: "https://novelfull.com/uploads/webp/novel/reverend-insanity-82661d911a.webp",
},
{
name: "lightnovelworld reads og image",
site: "lightnovelworld", body: lnwCoverFixture, wantOK: true,
wantCover: "https://i1.wp.com/lightnovelworld.net/wp-content/uploads/2025/10/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all.jpg",
},
{
name: "later metadata cover survives empty match",
site: "asura",
body: `<meta property="og:image" content="">` + asuraCoverFixture,
wantOK: true,
wantCover: "https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp",
},
{
name: "page without cover is empty",
site: "asura", body: `<meta property="og:title" content="No Cover">`,
},
{
name: "unknown site is empty",
site: "unknown", body: asuraCoverFixture,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := coverFrom(tt.site, tt.seriesURL, tt.body)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (got %q)", ok, tt.wantOK, got)
}
if got != tt.wantCover {
t.Errorf("cover = %q, want %q", got, tt.wantCover)
}
})
}
}
func TestCoverFromChallenge(t *testing.T) {
tests := []struct {
site string
seriesURL string
}{
{"asura", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"},
{"demonic", "https://demonicscans.org/manga/Catastrophic-Necromancer"},
{"comix", "https://comix.to/title/n8we-dungeons-and-crayons"},
{"kagane", "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"},
{"novelfull", "https://novelfull.com/reverend-insanity.html"},
{"lightnovelworld", "https://lightnovelworld.net/novel/a-will-eternal/"},
}
for _, tt := range tests {
t.Run(tt.site, func(t *testing.T) {
if got, ok := coverFrom(tt.site, tt.seriesURL, challengeFixture); ok || got != "" {
t.Fatalf("cover = %q, ok = %v, want empty", got, ok)
}
})
}
}
func TestLatestChapterFrom(t *testing.T) {
const asuraURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
const demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
tests := []struct {
name string
site string
seriesURL string
body string
wantOK bool
wantNum float64
wantLabel string
}{
{
name: "asura takes the max, not the last listed",
site: "asura", seriesURL: asuraURL, body: asuraSeriesFixture,
wantOK: true, wantNum: 181, wantLabel: "Chapter 181",
},
{
name: "asura ignores another series' chapter links",
site: "asura", seriesURL: asuraURL, body: asuraCrossSeriesFixture,
wantOK: true, wantNum: 181, wantLabel: "Chapter 181",
},
{
name: "asura scoping survives a build-hash rotation",
site: "asura",
seriesURL: "https://asurascans.com/comics/chronicles-of-the-demon-faction-059befe1",
body: asuraCrossSeriesFixture,
wantOK: true, wantNum: 181, wantLabel: "Chapter 181",
},
{
name: "asura with an unparseable series url",
site: "asura", seriesURL: "https://asurascans.com/", body: asuraSeriesFixture,
wantOK: false,
},
{
name: "demonic takes the max across raw and escaped ampersands",
site: "demonic", seriesURL: demonicURL, body: demonicSeriesFixture,
wantOK: true, wantNum: 296, wantLabel: "Chapter 296",
},
{
name: "demonic keeps decimal chapters parseable",
site: "demonic", seriesURL: demonicURL,
body: `<a href="/chaptered.php?manga=11799&chapter=0.5">Chapter 0.5</a>`,
wantOK: true, wantNum: 0.5, wantLabel: "Chapter 0.5",
},
{
name: "empty body",
site: "asura", seriesURL: asuraURL, body: "",
wantOK: false,
},
{
name: "cloudflare challenge page",
site: "asura", seriesURL: asuraURL, body: challengeFixture,
wantOK: false,
},
{
name: "demonic markup handed to the asura rule",
site: "asura", seriesURL: asuraURL, body: demonicSeriesFixture,
wantOK: false,
},
{
name: "unknown site",
site: "mangadex", seriesURL: "https://example.com/x", body: asuraSeriesFixture,
wantOK: false,
},
{
name: "comix reads latestChapterUrl, scoped to this series",
site: "comix",
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
body: comixSeriesFixture,
wantOK: true, wantNum: 80, wantLabel: "Chapter 80",
},
{
name: "comix yields nothing on a challenge page",
site: "comix",
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
body: challengeFixture,
wantOK: false,
},
{
name: "kagane takes the max chapter_no from API json",
site: "kagane",
seriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
body: kaganeAPIFixture,
wantOK: true, wantNum: 41, wantLabel: "Chapter 41",
},
{
name: "kagane yields nothing on a challenge page",
site: "kagane",
seriesURL: "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b",
body: challengeFixture,
wantOK: false,
},
{
name: "novelfull takes the max and ignores another series",
site: "novelfull",
seriesURL: "https://novelfull.com/reverend-insanity.html",
body: novelfullSeriesFixture,
wantOK: true, wantNum: 2334, wantLabel: "Chapter 2334",
},
{
name: "novelfull yields nothing on a challenge page",
site: "novelfull",
seriesURL: "https://novelfull.com/reverend-insanity.html",
body: challengeFixture,
wantOK: false,
},
{
name: "novelfull with an unparseable series url",
site: "novelfull",
seriesURL: "https://novelfull.com/genre/Fantasy",
body: novelfullSeriesFixture,
wantOK: false,
},
// Stored before the slug split, so the address carries the ...-not
// Chapter Slug; the 100-423 block under the other slug must still win.
// The body is the chapter-list portion of lnwSeriesFixture with the
// comment block omitted; the marker is kept, because a body without it
// is skipped, not scanned.
{
name: "lightnovelworld max spans both chapter slugs",
site: "lightnovelworld",
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not/",
body: strings.SplitN(lnwSeriesFixture, lnwCommentMarker, 2)[0] + lnwCommentMarker,
wantOK: true, wantNum: 423, wantLabel: "Chapter 423",
},
{
name: "lightnovelworld comment anchor cannot set the latest chapter",
site: "lightnovelworld",
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
body: lnwSeriesFixture,
wantOK: true, wantNum: 423, wantLabel: "Chapter 423",
},
// Marker removed from the fixture, comment block still present: a
// redesign must degrade into a skip, never into the comment's number.
{
name: "lightnovelworld body without the comment marker is skipped",
site: "lightnovelworld",
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
body: strings.ReplaceAll(lnwSeriesFixture, lnwCommentMarker, ""),
wantOK: false,
},
{
name: "lightnovelworld yields nothing on a challenge page",
site: "lightnovelworld",
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
body: challengeFixture,
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := latestChapterFrom(tt.site, tt.seriesURL, tt.body)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (got %+v)", ok, tt.wantOK, got)
}
if !tt.wantOK {
return
}
if got.Num != tt.wantNum {
t.Errorf("Num = %v, want %v", got.Num, tt.wantNum)
}
if got.Label != tt.wantLabel {
t.Errorf("Label = %q, want %q", got.Label, tt.wantLabel)
}
})
}
}