Implements spec #137 (spec 4 of 4 from wayfinder map #114). Closes #137. Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch. ## Summary - #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate. - #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion. - #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition. - #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing. - #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier. - #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree. `cd backend && go test ./...` green on the merged branch (8 packages). Reviewed-on: #174 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #174.
This commit is contained in:
@@ -188,6 +188,104 @@ const lnwSeriesFixture = `
|
||||
</div>
|
||||
`
|
||||
|
||||
// Completed-marker fixtures, trimmed from the live pages fetched 2026-08-22
|
||||
// for the verification step below. Selector-removed rows delete the marker
|
||||
// from the consts and must answer false.
|
||||
|
||||
// Trimmed from https://asurascans.com/comics/solo-leveling (301 to
|
||||
// /comics/solo-leveling-b60d532c) fetched 2026-08-22 by a curl probe from
|
||||
// this machine. The astro-island props are HTML-escaped in the served
|
||||
// document, so the quotes arrive as ".
|
||||
const asuraCompletedFixture = `
|
||||
"bookmarkCount":[0,39128],"status":[0,"completed"],"type":[0,"manhwa"]
|
||||
`
|
||||
|
||||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||||
// fetched 2026-08-22 by a curl probe.
|
||||
const asuraOngoingFixture = `
|
||||
"bookmarkCount":[0,54027],"status":[0,"ongoing"],"type":[0,"manhwa"]
|
||||
`
|
||||
|
||||
// Trimmed from https://demonicscans.org/manga/Solo-Leveling fetched
|
||||
// 2026-08-22 by a curl probe. The info block is sloppy: bare <li>s inside a
|
||||
// <div>, label and value as a sibling pair.
|
||||
const demonicCompletedFixture = `
|
||||
<div class="flex flex-row">
|
||||
<li style="width:150px;color:#b2b2b2;">Status</li>
|
||||
<li>Completed</li>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer
|
||||
// fetched 2026-08-22 by a curl probe. Same block, ongoing value.
|
||||
const demonicOngoingFixture = `
|
||||
<div class="flex flex-row">
|
||||
<li style="width:150px;color:#b2b2b2;">Status</li>
|
||||
<li>Ongoing</li>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Trimmed from https://comix.to/title/q77m-countach fetched 2026-08-22 by a
|
||||
// cleared Chrome tab (the CDP sidecar: the Series URL is fetched in-tab, the
|
||||
// same server-rendered payload the poll reads). The served page escapes the
|
||||
// query map keys as \u0022; the fixture carries the decoded form, which
|
||||
// parses to the same key. The recommended strip on this very page carries a
|
||||
// finished entry; only the ["manga","detail","q77m"] entry counts.
|
||||
const comixCompletedFixture = `<script type="application/json" id="initial-data">{"queries":{"[\"manga\",\"recommended\",\"q77m\",1]":{"items":[{"hid":"pz65","title":"Wangan Midnight: C1 Runner","status":"finished"}]},"[\"manga\",\"detail\",\"q77m\"]":{"id":13211,"hid":"q77m","title":"Countach","status":"finished","originalLanguage":"ja"}}}</script>`
|
||||
|
||||
// Trimmed from https://comix.to/title/n8we-dungeons-and-crayons fetched
|
||||
// 2026-08-22 the same way (keys in their decoded form, as above). The
|
||||
// recommended strip includes a finished entry; the target detail is
|
||||
// releasing, and only the detail read counts.
|
||||
const comixOngoingFixture = `<script type="application/json" id="initial-data">{"queries":{"[\"manga\",\"recommended\",\"n8we\",1]":{"items":[{"hid":"g2rk","title":"On the Way to Meet Mom","status":"finished"}]},"[\"manga\",\"detail\",\"n8we\"]":{"id":1429,"hid":"n8we","title":"Dungeons and Crayons","status":"releasing","originalLanguage":"ko"}}}</script>`
|
||||
|
||||
// Trimmed from GET https://kagane.to/api/v2/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b
|
||||
// fetched 2026-08-22 in a cleared Chrome tab (plain TLS serves the Cloudflare
|
||||
// challenge).
|
||||
const kaganeOngoingFixture = `{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption: The Strongest Level 0","publication_status":"Ongoing","upload_status":"Ongoing"}`
|
||||
|
||||
// Trimmed from GET https://kagane.to/api/v2/series/019c29c3-5abd-70e6-9efc-1f1f22d839f6
|
||||
// fetched 2026-08-22 the same way.
|
||||
const kaganeCompletedFixture = `{"series_id":"019c29c3-5abd-70e6-9efc-1f1f22d839f6","title":"Real Account 1-17","publication_status":"Completed","upload_status":"Completed"}`
|
||||
|
||||
// Composed, not a single live trim: upload Completed alongside a
|
||||
// non-Completed publication status is the research note's inferred inverse
|
||||
// case and did not turn up in the ~1900-series live scan of 2026-08-22 (both
|
||||
// field vocabularies are live-verified; the note's live divergence is 'Cause
|
||||
// Calypso Can, publication Ongoing + upload Hiatus). The predicate must read
|
||||
// publication_status only.
|
||||
const kaganeDivergentFixture = `{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption: The Strongest Level 0","publication_status":"Ongoing","upload_status":"Completed"}`
|
||||
|
||||
// Trimmed from https://novelfull.com/reverend-insanity.html fetched
|
||||
// 2026-08-22 in a cleared Chrome tab after the plain-TLS probe was served the
|
||||
// challenge (time-varying; plain curl answered 403 the same day).
|
||||
const novelfullCompletedFixture = `<div><h3>Status:</h3><a href="/status/Completed">Completed</a></div>`
|
||||
|
||||
// Trimmed from https://novelfull.com/a-cunning-pervert-in-the-cultivation-world.html
|
||||
// fetched 2026-08-22 the same way.
|
||||
const novelfullOngoingFixture = `<div><h3>Status:</h3><a href="/status/Ongoing">Ongoing</a></div>`
|
||||
|
||||
// Trimmed from the JSON-LD block in the head of
|
||||
// https://lightnovelworld.net/novel/a-will-eternal/ fetched 2026-08-22. The
|
||||
// block sits ahead of the wpdiscuz thread, so the predicate reads the whole
|
||||
// body and needs no comment marker, unlike lnwLatestChapter.
|
||||
const lnwCompletedFixture = `<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Book",
|
||||
"name": "A Will Eternal",
|
||||
"creativeWorkStatus": "https://schema.org/CompletedActionStatus"
|
||||
}</script>`
|
||||
|
||||
// Trimmed from
|
||||
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
|
||||
// fetched 2026-08-22. Same block, ongoing value.
|
||||
const lnwOngoingFixture = `<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Book",
|
||||
"name": "All Jobs and Classes I Just Wanted One Skill Not Them All",
|
||||
"creativeWorkStatus": "https://schema.org/ActiveActionStatus"
|
||||
}</script>`
|
||||
|
||||
// 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">`
|
||||
@@ -473,3 +571,178 @@ func TestLatestChapterFrom(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteCompletedFrom(t *testing.T) {
|
||||
const asuraURL = "https://asurascans.com/comics/solo-leveling-b60d532c"
|
||||
const demonicURL = "https://demonicscans.org/manga/Solo-Leveling"
|
||||
const comixURL = "https://comix.to/title/q77m-countach"
|
||||
const kaganeURL = "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
||||
const novelfullURL = "https://novelfull.com/reverend-insanity.html"
|
||||
const lnwURL = "https://lightnovelworld.net/novel/a-will-eternal/"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
site string
|
||||
seriesURL string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "asura completed via escaped props status",
|
||||
site: "asura", seriesURL: asuraURL, body: asuraCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "asura ongoing value is not completed",
|
||||
site: "asura",
|
||||
seriesURL: "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af",
|
||||
body: asuraOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "asura with the status key removed",
|
||||
site: "asura", seriesURL: asuraURL,
|
||||
body: strings.ReplaceAll(asuraCompletedFixture, `"status":[0,"completed"]`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "asura challenge page",
|
||||
site: "asura", seriesURL: asuraURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "demonic completed info-block pair",
|
||||
site: "demonic", seriesURL: demonicURL, body: demonicCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "demonic ongoing value is not completed",
|
||||
site: "demonic",
|
||||
seriesURL: "https://demonicscans.org/manga/Catastrophic-Necromancer",
|
||||
body: demonicOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "demonic with the value li removed",
|
||||
site: "demonic", seriesURL: demonicURL,
|
||||
body: strings.ReplaceAll(demonicCompletedFixture, "<li>Completed</li>", ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix recommended finished does not count",
|
||||
site: "comix",
|
||||
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
|
||||
body: comixOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix detail finished wins over a finished recommended strip",
|
||||
site: "comix",
|
||||
seriesURL: comixURL,
|
||||
body: comixCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "comix with the detail status removed",
|
||||
site: "comix", seriesURL: comixURL,
|
||||
body: strings.ReplaceAll(comixCompletedFixture, `"status":"finished",`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix challenge page",
|
||||
site: "comix", seriesURL: comixURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane publication Completed",
|
||||
site: "kagane",
|
||||
seriesURL: "https://kagane.to/series/019c29c3-5abd-70e6-9efc-1f1f22d839f6",
|
||||
body: kaganeCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "kagane upload Completed does not count",
|
||||
site: "kagane", seriesURL: kaganeURL, body: kaganeDivergentFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane ongoing values are not completed",
|
||||
site: "kagane", seriesURL: kaganeURL, body: kaganeOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane with publication_status removed",
|
||||
site: "kagane", seriesURL: kaganeURL,
|
||||
body: strings.ReplaceAll(kaganeCompletedFixture, `"publication_status":"Completed",`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane challenge page",
|
||||
site: "kagane", seriesURL: kaganeURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull status link Completed",
|
||||
site: "novelfull",
|
||||
seriesURL: novelfullURL,
|
||||
body: novelfullCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "novelfull ongoing link is not completed",
|
||||
site: "novelfull",
|
||||
seriesURL: "https://novelfull.com/a-cunning-pervert-in-the-cultivation-world.html",
|
||||
body: novelfullOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull with the status link removed",
|
||||
site: "novelfull", seriesURL: novelfullURL,
|
||||
body: strings.ReplaceAll(novelfullCompletedFixture, `<a href="/status/Completed">Completed</a>`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull challenge page",
|
||||
site: "novelfull", seriesURL: novelfullURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld JSON-LD CompletedActionStatus",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: lnwURL,
|
||||
body: lnwCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld ActiveActionStatus is not completed",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||
body: lnwOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld with creativeWorkStatus removed",
|
||||
site: "lightnovelworld", seriesURL: lnwURL,
|
||||
body: strings.ReplaceAll(lnwCompletedFixture, `"creativeWorkStatus": "https://schema.org/CompletedActionStatus"`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld challenge page",
|
||||
site: "lightnovelworld", seriesURL: lnwURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown site",
|
||||
site: "mangadex", seriesURL: "https://mangadex.org/title/x", body: asuraCompletedFixture,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := siteCompletedFrom(tt.site, tt.seriesURL, tt.body); got != tt.want {
|
||||
t.Errorf("siteCompletedFrom(%q, %q, body) = %v, want %v", tt.site, tt.seriesURL, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user