Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Replace blocking POST /api/audio with a non-blocking 202 flow: the Go
handler immediately enqueues a job in a new `audio_jobs` PocketBase
collection and returns {job_id, status}. A background goroutine runs
the actual Kokoro TTS work and updates job status (pending → generating
→ done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets
clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are
updated to POST, then poll the status route every 2s until done.
330 lines
12 KiB
Go
330 lines
12 KiB
Go
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/libnovel/scraper/internal/scraper"
|
|
"github.com/libnovel/scraper/internal/storage"
|
|
"io"
|
|
"log/slog"
|
|
)
|
|
|
|
// ── mock NovelScraper ─────────────────────────────────────────────────────────
|
|
|
|
type mockScraper struct {
|
|
catalogue []scraper.CatalogueEntry
|
|
meta scraper.BookMeta
|
|
metaErr error
|
|
chapters []scraper.ChapterRef
|
|
chapterTextFn func(ref scraper.ChapterRef) (scraper.Chapter, error)
|
|
}
|
|
|
|
func (m *mockScraper) SourceName() string { return "mock" }
|
|
|
|
func (m *mockScraper) ScrapeCatalogue(_ context.Context) (<-chan scraper.CatalogueEntry, <-chan error) {
|
|
entries := make(chan scraper.CatalogueEntry, len(m.catalogue))
|
|
errs := make(chan error, 1)
|
|
for _, e := range m.catalogue {
|
|
entries <- e
|
|
}
|
|
close(entries)
|
|
close(errs)
|
|
return entries, errs
|
|
}
|
|
|
|
func (m *mockScraper) ScrapeMetadata(_ context.Context, _ string) (scraper.BookMeta, error) {
|
|
return m.meta, m.metaErr
|
|
}
|
|
|
|
func (m *mockScraper) ScrapeChapterList(_ context.Context, _ string) ([]scraper.ChapterRef, error) {
|
|
return m.chapters, nil
|
|
}
|
|
|
|
func (m *mockScraper) ScrapeChapterText(_ context.Context, ref scraper.ChapterRef) (scraper.Chapter, error) {
|
|
if m.chapterTextFn != nil {
|
|
return m.chapterTextFn(ref)
|
|
}
|
|
return scraper.Chapter{Ref: ref, Text: "stub text"}, nil
|
|
}
|
|
|
|
func (m *mockScraper) ScrapeRanking(_ context.Context, _ int) (<-chan scraper.BookMeta, <-chan error) {
|
|
ch := make(chan scraper.BookMeta)
|
|
errs := make(chan error)
|
|
close(ch)
|
|
close(errs)
|
|
return ch, errs
|
|
}
|
|
|
|
// ── mock Store ────────────────────────────────────────────────────────────────
|
|
|
|
// mockStore records which methods were called; only implements what the
|
|
// orchestrator touches. All other methods panic so unexpected calls surface
|
|
// as test failures rather than silent no-ops.
|
|
type mockStore struct {
|
|
mu sync.Mutex
|
|
writtenMeta []scraper.BookMeta
|
|
writtenChapters []scraper.Chapter
|
|
existingSlugs map[string]map[int]bool // slug → chapterNum → exists
|
|
}
|
|
|
|
func newMockStore() *mockStore {
|
|
return &mockStore{existingSlugs: make(map[string]map[int]bool)}
|
|
}
|
|
|
|
func (s *mockStore) ChapterExists(_ context.Context, slug string, ref scraper.ChapterRef) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if m, ok := s.existingSlugs[slug]; ok {
|
|
return m[ref.Number]
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *mockStore) WriteChapter(_ context.Context, slug string, ch scraper.Chapter) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.writtenChapters = append(s.writtenChapters, ch)
|
|
return nil
|
|
}
|
|
|
|
func (s *mockStore) WriteChapterRefs(_ context.Context, _ string, _ []scraper.ChapterRef) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *mockStore) WriteMetadata(_ context.Context, meta scraper.BookMeta) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.writtenMeta = append(s.writtenMeta, meta)
|
|
return nil
|
|
}
|
|
|
|
// Unimplemented Store methods — panic so accidental calls surface immediately.
|
|
func (s *mockStore) ReadMetadata(_ context.Context, _ string) (scraper.BookMeta, bool, error) {
|
|
panic("ReadMetadata not expected")
|
|
}
|
|
func (s *mockStore) ListBooks(_ context.Context) ([]scraper.BookMeta, error) {
|
|
panic("ListBooks not expected")
|
|
}
|
|
func (s *mockStore) LocalSlugs(_ context.Context) (map[string]bool, error) {
|
|
panic("LocalSlugs not expected")
|
|
}
|
|
func (s *mockStore) MetadataMtime(_ context.Context, _ string) int64 { return 0 }
|
|
func (s *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, error) {
|
|
panic("ReadChapter not expected")
|
|
}
|
|
func (s *mockStore) ListChapters(_ context.Context, _ string) ([]storage.ChapterInfo, error) {
|
|
panic("ListChapters not expected")
|
|
}
|
|
func (s *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
|
func (s *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) {
|
|
panic("ReindexChapters not expected")
|
|
}
|
|
func (s *mockStore) WriteRankingItem(_ context.Context, _ storage.RankingItem) error { return nil }
|
|
func (s *mockStore) ReadRankingItems(_ context.Context) ([]storage.RankingItem, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool, error) {
|
|
return false, nil
|
|
}
|
|
func (s *mockStore) GetAudioCache(_ context.Context, _ string) (string, bool) { return "", false }
|
|
func (s *mockStore) SetAudioCache(_ context.Context, _, _ string) error { return nil }
|
|
func (s *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
|
func (s *mockStore) GetProgress(_ context.Context, _, _ string) (storage.ReadingProgress, bool) {
|
|
return storage.ReadingProgress{}, false
|
|
}
|
|
func (s *mockStore) SetProgress(_ context.Context, _ string, _ storage.ReadingProgress) error {
|
|
return nil
|
|
}
|
|
func (s *mockStore) AllProgress(_ context.Context, _ string) ([]storage.ReadingProgress, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *mockStore) DeleteProgress(_ context.Context, _, _ string) error { return nil }
|
|
func (s *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
|
|
|
func (s *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
|
func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
|
|
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
|
|
return "", false, nil
|
|
}
|
|
func (s *mockStore) BrowseHTMLKey(_ string, _ int) string { return "" }
|
|
func (s *mockStore) BrowseFilteredHTMLKey(_ string, _ int, _, _, _ string) string { return "" }
|
|
func (s *mockStore) BrowseCoverKey(_, _ string) string { return "" }
|
|
func (s *mockStore) SaveBrowseAsset(_ context.Context, _ string, _ []byte, _ string) error {
|
|
return nil
|
|
}
|
|
func (s *mockStore) GetBrowseAsset(_ context.Context, _ string) ([]byte, string, bool, error) {
|
|
return nil, "", false, nil
|
|
}
|
|
func (s *mockStore) CreateScrapeTask(_ context.Context, _, _ string) (string, error) {
|
|
return "task-id", nil
|
|
}
|
|
func (s *mockStore) UpdateScrapeTask(_ context.Context, _ string, _ storage.ScrapeTaskUpdate) error {
|
|
return nil
|
|
}
|
|
func (s *mockStore) ListScrapeTasks(_ context.Context) ([]storage.ScrapeTask, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *mockStore) CreateAudioJob(_ context.Context, _ string, _ int, _ string) (string, error) {
|
|
return "audio-job-id", nil
|
|
}
|
|
func (s *mockStore) UpdateAudioJob(_ context.Context, _, _, _ string, _ time.Time) error {
|
|
return nil
|
|
}
|
|
func (s *mockStore) GetAudioJob(_ context.Context, _ string) (storage.AudioJob, bool, error) {
|
|
return storage.AudioJob{}, false, nil
|
|
}
|
|
func (s *mockStore) ListAudioJobs(_ context.Context) ([]storage.AudioJob, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
// TestRun_SingleBook verifies the happy-path single-book scrape: metadata is
|
|
// persisted and all chapters are written to the store.
|
|
func TestRun_SingleBook(t *testing.T) {
|
|
novel := &mockScraper{
|
|
meta: scraper.BookMeta{Slug: "the-iron-throne", Title: "The Iron Throne"},
|
|
chapters: []scraper.ChapterRef{
|
|
{Number: 1, Title: "Chapter 1", URL: "https://example.com/book/ch-1"},
|
|
{Number: 2, Title: "Chapter 2", URL: "https://example.com/book/ch-2"},
|
|
{Number: 3, Title: "Chapter 3", URL: "https://example.com/book/ch-3"},
|
|
},
|
|
}
|
|
store := newMockStore()
|
|
|
|
o := New(Config{Workers: 2, SingleBookURL: "https://example.com/book/the-iron-throne"}, novel, discardLogger(), store)
|
|
if err := o.Run(context.Background()); err != nil {
|
|
t.Fatalf("Run() returned error: %v", err)
|
|
}
|
|
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
|
|
if len(store.writtenMeta) != 1 {
|
|
t.Errorf("writtenMeta count = %d, want 1", len(store.writtenMeta))
|
|
}
|
|
if len(store.writtenChapters) != 3 {
|
|
t.Errorf("writtenChapters count = %d, want 3", len(store.writtenChapters))
|
|
}
|
|
}
|
|
|
|
// TestRun_SingleBook_SkipsExistingChapters verifies that chapters already in
|
|
// the store are not re-scraped.
|
|
func TestRun_SingleBook_SkipsExistingChapters(t *testing.T) {
|
|
novel := &mockScraper{
|
|
meta: scraper.BookMeta{Slug: "test-novel", Title: "Test Novel"},
|
|
chapters: []scraper.ChapterRef{
|
|
{Number: 1, Title: "Chapter 1"},
|
|
{Number: 2, Title: "Chapter 2"},
|
|
},
|
|
}
|
|
store := newMockStore()
|
|
// Mark chapter 1 as already existing.
|
|
store.existingSlugs["test-novel"] = map[int]bool{1: true}
|
|
|
|
o := New(Config{Workers: 1, SingleBookURL: "https://example.com/book/test-novel"}, novel, discardLogger(), store)
|
|
if err := o.Run(context.Background()); err != nil {
|
|
t.Fatalf("Run() returned error: %v", err)
|
|
}
|
|
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
|
|
// Only chapter 2 should have been written; chapter 1 was skipped.
|
|
if len(store.writtenChapters) != 1 {
|
|
t.Errorf("writtenChapters count = %d, want 1 (skipped ch1)", len(store.writtenChapters))
|
|
}
|
|
if store.writtenChapters[0].Ref.Number != 2 {
|
|
t.Errorf("expected chapter 2 to be written, got chapter %d", store.writtenChapters[0].Ref.Number)
|
|
}
|
|
}
|
|
|
|
// TestRun_CatalogueMode verifies that catalogue mode processes all books.
|
|
func TestRun_CatalogueMode(t *testing.T) {
|
|
novel := &mockScraper{
|
|
catalogue: []scraper.CatalogueEntry{
|
|
{Title: "Book A", URL: "https://example.com/book/a"},
|
|
{Title: "Book B", URL: "https://example.com/book/b"},
|
|
},
|
|
meta: scraper.BookMeta{Slug: "book-slug", Title: "A Book"},
|
|
chapters: []scraper.ChapterRef{{Number: 1, Title: "Chapter 1"}},
|
|
}
|
|
store := newMockStore()
|
|
|
|
o := New(Config{Workers: 2}, novel, discardLogger(), store)
|
|
if err := o.Run(context.Background()); err != nil {
|
|
t.Fatalf("Run() returned error: %v", err)
|
|
}
|
|
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
|
|
// 2 books → 2 metadata writes, 2 chapter writes (one chapter per book).
|
|
if len(store.writtenMeta) != 2 {
|
|
t.Errorf("writtenMeta count = %d, want 2", len(store.writtenMeta))
|
|
}
|
|
if len(store.writtenChapters) != 2 {
|
|
t.Errorf("writtenChapters count = %d, want 2", len(store.writtenChapters))
|
|
}
|
|
}
|
|
|
|
// TestRun_OnProgress_Called verifies that the OnProgress callback fires at
|
|
// least once upon completion.
|
|
func TestRun_OnProgress_Called(t *testing.T) {
|
|
novel := &mockScraper{
|
|
meta: scraper.BookMeta{Slug: "progress-book", Title: "Progress Book"},
|
|
chapters: []scraper.ChapterRef{{Number: 1, Title: "Chapter 1"}},
|
|
}
|
|
store := newMockStore()
|
|
|
|
var callCount int
|
|
o := New(Config{
|
|
Workers: 1,
|
|
SingleBookURL: "https://example.com/book/progress-book",
|
|
OnProgress: func(_ Progress) {
|
|
callCount++
|
|
},
|
|
}, novel, discardLogger(), store)
|
|
|
|
if err := o.Run(context.Background()); err != nil {
|
|
t.Fatalf("Run() returned error: %v", err)
|
|
}
|
|
if callCount == 0 {
|
|
t.Error("OnProgress was never called")
|
|
}
|
|
}
|
|
|
|
// TestRun_ContextCancelled verifies that Run returns a non-nil error when the
|
|
// context is cancelled before work completes.
|
|
func TestRun_ContextCancelled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
novel := &mockScraper{
|
|
meta: scraper.BookMeta{Slug: "cancel-book", Title: "Cancel Book"},
|
|
chapters: []scraper.ChapterRef{{Number: 1}},
|
|
}
|
|
store := newMockStore()
|
|
|
|
o := New(Config{Workers: 1, SingleBookURL: "https://example.com/book/cancel-book"}, novel, discardLogger(), store)
|
|
err := o.Run(ctx)
|
|
if err == nil {
|
|
t.Error("expected non-nil error when context is cancelled, got nil")
|
|
}
|
|
}
|