package orchestrator import ( "context" "errors" "sync" "testing" "github.com/libnovel/backend/internal/domain" ) // ── stubs ───────────────────────────────────────────────────────────────────── type stubScraper struct { meta domain.BookMeta metaErr error refs []domain.ChapterRef refsErr error chapters map[int]domain.Chapter chapErr map[int]error } func (s *stubScraper) SourceName() string { return "stub" } func (s *stubScraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) { ch := make(chan domain.CatalogueEntry) errs := make(chan error) close(ch) close(errs) return ch, errs } func (s *stubScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMeta, error) { return s.meta, s.metaErr } func (s *stubScraper) ScrapeChapterList(_ context.Context, _ string, _ int) ([]domain.ChapterRef, error) { return s.refs, s.refsErr } func (s *stubScraper) ScrapeChapterText(_ context.Context, ref domain.ChapterRef) (domain.Chapter, error) { if s.chapErr != nil { if err, ok := s.chapErr[ref.Number]; ok { return domain.Chapter{}, err } } if s.chapters != nil { if ch, ok := s.chapters[ref.Number]; ok { return ch, nil } } return domain.Chapter{Ref: ref, Text: "text"}, nil } func (s *stubScraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) { ch := make(chan domain.BookMeta) errs := make(chan error) close(ch) close(errs) return ch, errs } type stubStore struct { mu sync.Mutex metaWritten []domain.BookMeta chaptersWritten []domain.Chapter existing map[string]bool // "slug:N" → exists writeMetaErr error } func (s *stubStore) WriteMetadata(_ context.Context, meta domain.BookMeta) error { s.mu.Lock() defer s.mu.Unlock() if s.writeMetaErr != nil { return s.writeMetaErr } s.metaWritten = append(s.metaWritten, meta) return nil } func (s *stubStore) WriteChapter(_ context.Context, slug string, ch domain.Chapter) error { s.mu.Lock() defer s.mu.Unlock() s.chaptersWritten = append(s.chaptersWritten, ch) return nil } func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error { return nil } func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil } func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool { s.mu.Lock() defer s.mu.Unlock() key := slug + ":" + string(rune('0'+ref.Number)) return s.existing[key] } // ── tests ────────────────────────────────────────────────────────────────────── func TestRunBook_HappyPath(t *testing.T) { sc := &stubScraper{ meta: domain.BookMeta{Slug: "test-book", Title: "Test Book", SourceURL: "https://example.com/book/test-book"}, refs: []domain.ChapterRef{ {Number: 1, Title: "Ch 1", URL: "https://example.com/book/test-book/chapter-1"}, {Number: 2, Title: "Ch 2", URL: "https://example.com/book/test-book/chapter-2"}, {Number: 3, Title: "Ch 3", URL: "https://example.com/book/test-book/chapter-3"}, }, } st := &stubStore{} o := New(Config{Workers: 2}, sc, st, nil) task := domain.ScrapeTask{ ID: "t1", Kind: "book", TargetURL: "https://example.com/book/test-book", } result := o.RunBook(context.Background(), task) if result.ErrorMessage != "" { t.Fatalf("unexpected error: %s", result.ErrorMessage) } if result.BooksFound != 1 { t.Errorf("BooksFound = %d, want 1", result.BooksFound) } if result.ChaptersScraped != 3 { t.Errorf("ChaptersScraped = %d, want 3", result.ChaptersScraped) } } func TestRunBook_MetadataError(t *testing.T) { sc := &stubScraper{metaErr: errors.New("404 not found")} st := &stubStore{} o := New(Config{Workers: 1}, sc, st, nil) result := o.RunBook(context.Background(), domain.ScrapeTask{ ID: "t2", TargetURL: "https://example.com/book/missing", }) if result.ErrorMessage == "" { t.Fatal("expected ErrorMessage to be set") } if result.Errors != 1 { t.Errorf("Errors = %d, want 1", result.Errors) } } func TestRunBook_ChapterRange(t *testing.T) { sc := &stubScraper{ meta: domain.BookMeta{Slug: "range-book", SourceURL: "https://example.com/book/range-book"}, refs: func() []domain.ChapterRef { var refs []domain.ChapterRef for i := 1; i <= 10; i++ { refs = append(refs, domain.ChapterRef{Number: i, URL: "https://example.com/book/range-book/chapter-" + string(rune('0'+i))}) } return refs }(), } st := &stubStore{} o := New(Config{Workers: 2}, sc, st, nil) result := o.RunBook(context.Background(), domain.ScrapeTask{ ID: "t3", TargetURL: "https://example.com/book/range-book", FromChapter: 3, ToChapter: 7, }) if result.ErrorMessage != "" { t.Fatalf("unexpected error: %s", result.ErrorMessage) } // chapters 3–7 = 5 scraped, chapters 1-2 and 8-10 = 5 skipped if result.ChaptersScraped != 5 { t.Errorf("ChaptersScraped = %d, want 5", result.ChaptersScraped) } if result.ChaptersSkipped != 5 { t.Errorf("ChaptersSkipped = %d, want 5", result.ChaptersSkipped) } } func TestRunBook_ContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() sc := &stubScraper{ meta: domain.BookMeta{Slug: "ctx-book", SourceURL: "https://example.com/book/ctx-book"}, refs: []domain.ChapterRef{ {Number: 1, URL: "https://example.com/book/ctx-book/chapter-1"}, }, } st := &stubStore{} o := New(Config{Workers: 1}, sc, st, nil) // Should not panic; result may have errors or zero chapters. result := o.RunBook(ctx, domain.ScrapeTask{ ID: "t4", TargetURL: "https://example.com/book/ctx-book", }) _ = result } func TestRunBook_EmptyTargetURL(t *testing.T) { o := New(Config{Workers: 1}, &stubScraper{}, &stubStore{}, nil) result := o.RunBook(context.Background(), domain.ScrapeTask{ID: "t5"}) if result.ErrorMessage == "" { t.Fatal("expected ErrorMessage for empty target URL") } }