Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Successful in 28s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 49s
Release / Docker / caddy (push) Successful in 59s
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Successful in 46s
Release / Docker / ui (push) Successful in 1m31s
Release / Docker / backend (push) Successful in 3m27s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Failing after 32s
- Add StreamAudioWAV() to pocket-tts and Kokoro clients; pocket-tts streams
raw WAV directly (no ffmpeg), Kokoro requests response_format:wav with stream:true
- GET /api/audio-stream supports ?format=wav for lower-latency first-byte delivery;
WAV cached separately in MinIO as {slug}/{n}/{voice}.wav
- Add GET /api/admin/audio/jobs with optional ?slug filter
- Add POST /api/admin/audio/bulk {slug, voice, from, to, skip_existing, force}
where skip_existing=true (default) resumes interrupted bulk jobs
- Add POST /api/admin/audio/cancel-bulk {slug} to cancel all pending/running tasks
- Add CancelAudioTasksBySlug to taskqueue.Producer + asynqqueue implementation
- Add AudioObjectKeyExt to bookstore.AudioStore for format-aware MinIO keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
5.4 KiB
Go
155 lines
5.4 KiB
Go
package taskqueue_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/libnovel/backend/internal/domain"
|
|
"github.com/libnovel/backend/internal/taskqueue"
|
|
)
|
|
|
|
// ── Compile-time interface satisfaction ───────────────────────────────────────
|
|
|
|
// stubStore satisfies all three taskqueue interfaces.
|
|
// Any method that is called but not expected panics — making accidental
|
|
// calls immediately visible in tests.
|
|
type stubStore struct{}
|
|
|
|
func (s *stubStore) CreateScrapeTask(_ context.Context, _, _ string, _, _ int) (string, error) {
|
|
return "task-1", nil
|
|
}
|
|
func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
|
return "audio-1", nil
|
|
}
|
|
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
|
return "translation-1", nil
|
|
}
|
|
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
|
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
|
|
|
|
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
|
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil
|
|
}
|
|
func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
|
|
return domain.AudioTask{ID: "audio-1", Status: domain.TaskStatusRunning}, true, nil
|
|
}
|
|
func (s *stubStore) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
|
return domain.TranslationTask{ID: "translation-1", Status: domain.TaskStatusRunning}, true, nil
|
|
}
|
|
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
|
|
return nil
|
|
}
|
|
func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioResult) error {
|
|
return nil
|
|
}
|
|
func (s *stubStore) FinishTranslationTask(_ context.Context, _ string, _ domain.TranslationResult) error {
|
|
return nil
|
|
}
|
|
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
|
|
|
|
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
|
|
|
func (s *stubStore) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (s *stubStore) ListScrapeTasks(_ context.Context) ([]domain.ScrapeTask, error) { return nil, nil }
|
|
func (s *stubStore) GetScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
|
return domain.ScrapeTask{}, false, nil
|
|
}
|
|
func (s *stubStore) ListAudioTasks(_ context.Context) ([]domain.AudioTask, error) { return nil, nil }
|
|
func (s *stubStore) GetAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
|
|
return domain.AudioTask{}, false, nil
|
|
}
|
|
func (s *stubStore) ListTranslationTasks(_ context.Context) ([]domain.TranslationTask, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubStore) GetTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
|
return domain.TranslationTask{}, false, nil
|
|
}
|
|
|
|
// Verify the stub satisfies all three interfaces at compile time.
|
|
var _ taskqueue.Producer = (*stubStore)(nil)
|
|
var _ taskqueue.Consumer = (*stubStore)(nil)
|
|
var _ taskqueue.Reader = (*stubStore)(nil)
|
|
|
|
// ── Behavioural tests (using stub) ────────────────────────────────────────────
|
|
|
|
func TestProducer_CreateScrapeTask(t *testing.T) {
|
|
var p taskqueue.Producer = &stubStore{}
|
|
id, err := p.CreateScrapeTask(context.Background(), "book", "https://example.com/book/slug", 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if id == "" {
|
|
t.Error("expected non-empty task ID")
|
|
}
|
|
}
|
|
|
|
func TestConsumer_ClaimNextScrapeTask(t *testing.T) {
|
|
var c taskqueue.Consumer = &stubStore{}
|
|
task, ok, err := c.ClaimNextScrapeTask(context.Background(), "worker-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected a task to be claimed")
|
|
}
|
|
if task.Status != domain.TaskStatusRunning {
|
|
t.Errorf("want running, got %q", task.Status)
|
|
}
|
|
}
|
|
|
|
func TestConsumer_ClaimNextAudioTask(t *testing.T) {
|
|
var c taskqueue.Consumer = &stubStore{}
|
|
task, ok, err := c.ClaimNextAudioTask(context.Background(), "worker-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected an audio task to be claimed")
|
|
}
|
|
if task.ID == "" {
|
|
t.Error("expected non-empty task ID")
|
|
}
|
|
}
|
|
|
|
// ── domain.ScrapeResult / domain.AudioResult JSON shape ──────────────────────
|
|
|
|
func TestScrapeResult_JSONRoundtrip(t *testing.T) {
|
|
cases := []domain.ScrapeResult{
|
|
{BooksFound: 5, ChaptersScraped: 100, ChaptersSkipped: 2, Errors: 0},
|
|
{BooksFound: 0, ChaptersScraped: 0, Errors: 1, ErrorMessage: "timeout"},
|
|
}
|
|
for _, orig := range cases {
|
|
b, err := json.Marshal(orig)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
var got domain.ScrapeResult
|
|
if err := json.Unmarshal(b, &got); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if got != orig {
|
|
t.Errorf("want %+v, got %+v", orig, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAudioResult_JSONRoundtrip(t *testing.T) {
|
|
cases := []domain.AudioResult{
|
|
{ObjectKey: "audio/slug/1/af_bella.mp3"},
|
|
{ErrorMessage: "kokoro unavailable"},
|
|
}
|
|
for _, orig := range cases {
|
|
b, _ := json.Marshal(orig)
|
|
var got domain.AudioResult
|
|
json.Unmarshal(b, &got)
|
|
if got != orig {
|
|
t.Errorf("want %+v, got %+v", orig, got)
|
|
}
|
|
}
|
|
}
|