diff --git a/backend/internal/latest/smoke_lnw_test.go b/backend/internal/latest/smoke_lnw_test.go new file mode 100644 index 0000000..cd3e589 --- /dev/null +++ b/backend/internal/latest/smoke_lnw_test.go @@ -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 +}