All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m19s
Release / Docker / ui (push) Successful in 3m12s
Release / Gitea Release (push) Successful in 1m22s
213 lines
6.1 KiB
Go
213 lines
6.1 KiB
Go
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")
|
||
}
|
||
}
|