Merge ticket/88-lnw-marker-canary (#88)
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
package latest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lnwSeriesPageFloor is the smallest body that can still be a whole
|
||||||
|
// lightnovelworld Series page. Whole pages measured 685 KB..1.18 MB on
|
||||||
|
// 2026-08-11 and carry the marker at ~94% of the document, so a body under
|
||||||
|
// 100 KB is a challenge, a notice, or a truncated read — asserting on it
|
||||||
|
// would report the marker missing when it was never fetched.
|
||||||
|
const lnwSeriesPageFloor = 100 << 10
|
||||||
|
|
||||||
|
// TestSmokeLnwCommentBoundary is the live proof that the comment-thread marker
|
||||||
|
// the lightnovelworld chapter scan truncates at (lnwCommentMarker,
|
||||||
|
// "wpd-threads") still holds on the Site. The scan depends on it: when the
|
||||||
|
// marker vanishes every Series is skipped and logged — correct, but silent
|
||||||
|
// until a Reader notices their Latest Chapter has stopped moving. It runs only
|
||||||
|
// when SMOKE_LNW_SERIES_URL is set — the URL of the live Series page to check.
|
||||||
|
// The immortality-simulator page measured 2026-08-11
|
||||||
|
// (docs/research/lightnovelworld-chapter-vs-series-slug.md) is the default to
|
||||||
|
// point it at:
|
||||||
|
//
|
||||||
|
// SMOKE_LNW_SERIES_URL=https://lightnovelworld.net/novel/immortality-simulator/ go test -v -run TestSmokeLnwCommentBoundary ./internal/latest
|
||||||
|
//
|
||||||
|
// A red run means the Site's markup has moved — the marker is gone, occurs
|
||||||
|
// more than once, or no longer follows the last chapter anchor — and the scan
|
||||||
|
// in sites.go is now skipping this Site. Revisit sites.go before anything
|
||||||
|
// else; the test is not flaky. A Cloudflare challenge or a non-200 is
|
||||||
|
// distinguished from a marker failure by the "not a marker failure" messages
|
||||||
|
// below, which carry the observed status and body length.
|
||||||
|
func TestSmokeLnwCommentBoundary(t *testing.T) {
|
||||||
|
seriesURL := os.Getenv("SMOKE_LNW_SERIES_URL")
|
||||||
|
if seriesURL == "" {
|
||||||
|
t.Skip("SMOKE_LNW_SERIES_URL unset")
|
||||||
|
}
|
||||||
|
if !fetchableSeriesURL("lightnovelworld", seriesURL) {
|
||||||
|
t.Fatalf("%q is not a fetchable lightnovelworld series URL", seriesURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := NewTLSFetcher()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTLSFetcher: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
body, status, err := f.Get(ctx, seriesURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body %d bytes — not a marker failure; the Site did not answer this IP with a Series page", status, len(body))
|
||||||
|
}
|
||||||
|
if len(body) < lnwSeriesPageFloor {
|
||||||
|
t.Fatalf("body %d bytes — not a whole Series page (measured 685 KB..1.18 MB); not a marker failure, likely a Cloudflare challenge or a non-Series response", len(body))
|
||||||
|
}
|
||||||
|
if !lnwChapterRe.MatchString(body) {
|
||||||
|
t.Fatalf("no chapter anchor in %d bytes — not a lightnovelworld Series page; not a marker failure, likely a Cloudflare challenge or a non-Series response", len(body))
|
||||||
|
}
|
||||||
|
markerIdx := strings.Index(body, lnwCommentMarker)
|
||||||
|
if failures := checkLnwCommentBoundary(body); len(failures) > 0 {
|
||||||
|
t.Fatalf("%s (body %d bytes)", strings.Join(failures, "; "), len(body))
|
||||||
|
}
|
||||||
|
t.Logf("ok: %q once at byte %d, body %d bytes", lnwCommentMarker, markerIdx, len(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkLnwCommentBoundary verifies the three marker assertions against a
|
||||||
|
// fetched Series body: the marker occurs exactly once, every chapter anchor
|
||||||
|
// precedes it, and at least one anchor precedes it at all. It returns one
|
||||||
|
// human-readable failure per broken assertion — with observed offsets and body
|
||||||
|
// length — and empty when the page is healthy.
|
||||||
|
func checkLnwCommentBoundary(body string) []string {
|
||||||
|
markerIdx := strings.Index(body, lnwCommentMarker)
|
||||||
|
switch n := strings.Count(body, lnwCommentMarker); {
|
||||||
|
case n == 0:
|
||||||
|
return []string{fmt.Sprintf("%q occurs 0 times in %d bytes, want exactly 1", lnwCommentMarker, len(body))}
|
||||||
|
case n != 1:
|
||||||
|
return []string{fmt.Sprintf("%q occurs %d times in %d bytes (first at byte %d), want exactly 1", lnwCommentMarker, n, len(body), markerIdx)}
|
||||||
|
}
|
||||||
|
lastAnchor, anchorsBefore := -1, 0
|
||||||
|
for _, m := range lnwChapterRe.FindAllStringIndex(body, -1) {
|
||||||
|
if m[0] < markerIdx {
|
||||||
|
anchorsBefore++
|
||||||
|
}
|
||||||
|
lastAnchor = m[0]
|
||||||
|
}
|
||||||
|
var failures []string
|
||||||
|
if lastAnchor >= markerIdx {
|
||||||
|
failures = append(failures, fmt.Sprintf("last chapter anchor at byte %d does not precede the marker at byte %d", lastAnchor, markerIdx))
|
||||||
|
}
|
||||||
|
if anchorsBefore == 0 {
|
||||||
|
failures = append(failures, fmt.Sprintf("no chapter anchor before the marker at byte %d — the truncated prefix the scan sees yields nothing", markerIdx))
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user