- New Go backend binary (backend + runner) replacing old scraper/ - Rename SCRAPER_API_URL → BACKEND_API_URL in UI env and docker-compose - Rename scraperFetch → backendFetch across all 19 UI server files - Remove SCRAPER_PROXY env var and proxy transport from browser.Config - Add Meilisearch, Valkey, Caddy to docker-compose - Add docs/: api-endpoints.md, request-flow.mermaid.md, data-flow.mermaid.md
130 lines
3.2 KiB
Go
130 lines
3.2 KiB
Go
package novelfire
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func TestSlugFromURL(t *testing.T) {
|
|
cases := []struct {
|
|
url string
|
|
want string
|
|
}{
|
|
{"https://novelfire.net/book/shadow-slave", "shadow-slave"},
|
|
{"https://novelfire.net/book/a-dragon-against-the-whole-world", "a-dragon-against-the-whole-world"},
|
|
{"https://novelfire.net/book/foo/chapter-1", "foo"},
|
|
{"https://novelfire.net/", ""},
|
|
{"not-a-url", "not-a-url"},
|
|
}
|
|
for _, c := range cases {
|
|
got := slugFromURL(c.url)
|
|
if got != c.want {
|
|
t.Errorf("slugFromURL(%q) = %q, want %q", c.url, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChapterNumberFromURL(t *testing.T) {
|
|
cases := []struct {
|
|
url string
|
|
want int
|
|
}{
|
|
{"https://novelfire.net/book/shadow-slave/chapter-42", 42},
|
|
{"https://novelfire.net/book/shadow-slave/chapter-1000", 1000},
|
|
{"https://novelfire.net/book/shadow-slave/chap-7", 7},
|
|
{"https://novelfire.net/book/shadow-slave/ch-3", 3},
|
|
{"https://novelfire.net/book/shadow-slave/42", 42},
|
|
{"https://novelfire.net/book/shadow-slave/no-number-here", 0},
|
|
{"not-a-url", 0},
|
|
}
|
|
for _, c := range cases {
|
|
got := chapterNumberFromURL(c.url)
|
|
if got != c.want {
|
|
t.Errorf("chapterNumberFromURL(%q) = %d, want %d", c.url, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseChapterCount(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want int
|
|
}{
|
|
{"123 Chapters", 123},
|
|
{"1,234 Chapters", 1234},
|
|
{"0", 0},
|
|
{"", 0},
|
|
{"500", 500},
|
|
}
|
|
for _, c := range cases {
|
|
got := parseChapterCount(c.in)
|
|
if got != c.want {
|
|
t.Errorf("parseChapterCount(%q) = %d, want %d", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRetryGet_ContextCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
stub := newStubClient()
|
|
stub.setError("https://example.com/page", context.Canceled)
|
|
|
|
_, err := retryGet(ctx, nil, stub, "https://example.com/page", 3, 0)
|
|
if err == nil {
|
|
t.Fatal("expected error on cancelled context")
|
|
}
|
|
}
|
|
|
|
func TestRetryGet_EventualSuccess(t *testing.T) {
|
|
stub := newStubClient()
|
|
calls := 0
|
|
stub.setFn("https://example.com/page", func() (string, error) {
|
|
calls++
|
|
if calls < 3 {
|
|
return "", context.DeadlineExceeded
|
|
}
|
|
return "<html>ok</html>", nil
|
|
})
|
|
|
|
got, err := retryGet(context.Background(), nil, stub, "https://example.com/page", 5, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "<html>ok</html>" {
|
|
t.Errorf("got %q, want html", got)
|
|
}
|
|
if calls != 3 {
|
|
t.Errorf("expected 3 calls, got %d", calls)
|
|
}
|
|
}
|
|
|
|
// ── minimal stub client for tests ─────────────────────────────────────────────
|
|
|
|
type stubClient struct {
|
|
errors map[string]error
|
|
fns map[string]func() (string, error)
|
|
}
|
|
|
|
func newStubClient() *stubClient {
|
|
return &stubClient{
|
|
errors: make(map[string]error),
|
|
fns: make(map[string]func() (string, error)),
|
|
}
|
|
}
|
|
|
|
func (s *stubClient) setError(u string, err error) { s.errors[u] = err }
|
|
|
|
func (s *stubClient) setFn(u string, fn func() (string, error)) { s.fns[u] = fn }
|
|
|
|
func (s *stubClient) GetContent(_ context.Context, pageURL string) (string, error) {
|
|
if fn, ok := s.fns[pageURL]; ok {
|
|
return fn()
|
|
}
|
|
if err, ok := s.errors[pageURL]; ok {
|
|
return "", err
|
|
}
|
|
return "", context.DeadlineExceeded
|
|
}
|