Give the backfill task a default 15-minute interval trigger. With the rate-limit cooldown floor each run meets a fresh ready-set, a saturated batch trips the zero-progress breaker, and an empty lane exits in milliseconds, so the backlog drains at provider speed unattended. The canary claim cap and batch delay keep their semantics, and operators can retune or disable the trigger through the admin task UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
485 lines
17 KiB
Go
485 lines
17 KiB
Go
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/ebooks"
|
|
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
|
)
|
|
|
|
type fakeEbookMetadataEnricher struct {
|
|
results []ebooks.EnrichmentRunResult
|
|
err error
|
|
countErr error
|
|
scopes []ebooks.EnrichmentScope
|
|
limits []int
|
|
readyCountCalls int
|
|
initialReady int
|
|
onRun func(int)
|
|
}
|
|
|
|
func (f *fakeEbookMetadataEnricher) ReadyCount(_ context.Context, _ ebooks.EnrichmentScope) (int, error) {
|
|
f.readyCountCalls++
|
|
if f.countErr != nil {
|
|
return 0, f.countErr
|
|
}
|
|
if f.initialReady > 0 || len(f.results) == 0 {
|
|
return f.initialReady, nil
|
|
}
|
|
return f.results[0].Claimed + f.results[0].Remaining, nil
|
|
}
|
|
|
|
func (f *fakeEbookMetadataEnricher) RunLimited(_ context.Context, scope ebooks.EnrichmentScope, limit int) (ebooks.EnrichmentRunResult, error) {
|
|
f.scopes = append(f.scopes, scope)
|
|
f.limits = append(f.limits, limit)
|
|
call := len(f.scopes)
|
|
if f.onRun != nil {
|
|
f.onRun(call)
|
|
}
|
|
if f.err != nil {
|
|
return ebooks.EnrichmentRunResult{}, f.err
|
|
}
|
|
if call > len(f.results) {
|
|
return ebooks.EnrichmentRunResult{}, nil
|
|
}
|
|
result := f.results[call-1]
|
|
result.HasMore = result.Remaining > 0
|
|
return result, nil
|
|
}
|
|
|
|
type ebookMetadataProgressReporter struct {
|
|
percents []float64
|
|
messages []string
|
|
results []json.RawMessage
|
|
}
|
|
|
|
func (p *ebookMetadataProgressReporter) Report(percent float64, message string) {
|
|
p.percents = append(p.percents, percent)
|
|
p.messages = append(p.messages, message)
|
|
}
|
|
|
|
func (p *ebookMetadataProgressReporter) SetResultData(data json.RawMessage) {
|
|
p.results = append(p.results, append(json.RawMessage(nil), data...))
|
|
}
|
|
|
|
func TestEbookMetadataTaskPropertiesAndScopes(t *testing.T) {
|
|
enricher := &fakeEbookMetadataEnricher{}
|
|
syncTask := NewSyncEbookMetadataTask(enricher)
|
|
backfillTask := NewBackfillEbookMetadataTask(enricher)
|
|
|
|
if syncTask.Key() != "sync_ebook_metadata" || syncTask.Name() != "Sync Ebook Metadata" {
|
|
t.Fatalf("unexpected sync identity: %q %q", syncTask.Key(), syncTask.Name())
|
|
}
|
|
if backfillTask.Key() != "backfill_ebook_metadata" {
|
|
t.Fatalf("backfill Key() = %q", backfillTask.Key())
|
|
}
|
|
if !strings.Contains(strings.ToLower(backfillTask.Description()), "legacy") {
|
|
t.Fatalf("backfill description does not explain legacy work: %q", backfillTask.Description())
|
|
}
|
|
for _, task := range []taskmanager.Task{syncTask, backfillTask} {
|
|
if task.Category() != taskmanager.TaskCategoryMetadata || task.IsHidden() {
|
|
t.Fatalf("unexpected task properties for %q", task.Key())
|
|
}
|
|
}
|
|
triggers := syncTask.DefaultTriggers()
|
|
if len(triggers) != 1 || triggers[0].Type != taskmanager.TriggerTypeInterval || triggers[0].IntervalMs != 5*60*1000 {
|
|
t.Fatalf("sync DefaultTriggers() = %#v", triggers)
|
|
}
|
|
backfillTriggers := backfillTask.DefaultTriggers()
|
|
if len(backfillTriggers) != 1 ||
|
|
backfillTriggers[0].Type != taskmanager.TriggerTypeInterval ||
|
|
backfillTriggers[0].IntervalMs != 15*60*1000 {
|
|
t.Fatalf("backfill DefaultTriggers() = %#v, want a 15m interval", backfillTriggers)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataTaskDrainsBatchesAndReportsHonestProgress(t *testing.T) {
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 4, Enriched: 2, NoMatch: 1, Failed: 1, Remaining: 3},
|
|
{Claimed: 3, Enriched: 1, Deferred: 2, Remaining: 0},
|
|
}}
|
|
task := NewSyncEbookMetadataTask(enricher)
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
if err := task.Execute(context.Background(), progress); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if len(enricher.scopes) != 2 {
|
|
t.Fatalf("Run calls = %d, want 2", len(enricher.scopes))
|
|
}
|
|
if enricher.readyCountCalls != 1 {
|
|
t.Fatalf("initial exact ready counts = %d, want 1", enricher.readyCountCalls)
|
|
}
|
|
for _, scope := range enricher.scopes {
|
|
if scope != ebooks.EnrichmentScopeIncremental {
|
|
t.Fatalf("sync scope = %q, want incremental", scope)
|
|
}
|
|
}
|
|
var result ebooks.EnrichmentRunResult
|
|
if err := json.Unmarshal(progress.results[len(progress.results)-1], &result); err != nil {
|
|
t.Fatalf("result JSON error: %v", err)
|
|
}
|
|
want := ebooks.EnrichmentRunResult{Claimed: 7, Enriched: 3, NoMatch: 1, Failed: 1, Deferred: 2}
|
|
if result != want {
|
|
t.Fatalf("result = %+v, want %+v", result, want)
|
|
}
|
|
if progress.percents[1] >= 100 {
|
|
t.Fatalf("first batch progress = %.1f, must be below 100 with remaining work", progress.percents[1])
|
|
}
|
|
if got := progress.percents[len(progress.percents)-1]; got != 100 {
|
|
t.Fatalf("final progress = %.1f, want 100", got)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataTaskDoesNotReportCompleteWhileBoundedCheckFindsMoreWork(t *testing.T) {
|
|
enricher := &fakeEbookMetadataEnricher{
|
|
initialReady: 1,
|
|
results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 1, Enriched: 1, Remaining: 1},
|
|
{Claimed: 1, Enriched: 1},
|
|
},
|
|
}
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
if err := NewSyncEbookMetadataTask(enricher).Execute(context.Background(), progress); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if got := progress.percents[1]; got >= 100 {
|
|
t.Fatalf("first batch progress = %.1f, must stay below 100 while HasMore is true", got)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataTaskStopsAfterOneAllFailedBatch(t *testing.T) {
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "1")
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", "1h")
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 4, Failed: 4, Remaining: 100},
|
|
{Claimed: 4, Enriched: 4, Remaining: 96},
|
|
}}
|
|
progress := &ebookMetadataProgressReporter{}
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
task.sleep = func(context.Context, time.Duration) error {
|
|
t.Fatal("no-progress batch must circuit-break before pacing")
|
|
return nil
|
|
}
|
|
|
|
if err := task.Execute(context.Background(), progress); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
assertNoProgressCircuitBreak(t, enricher, progress, ebooks.EnrichmentRunResult{
|
|
Claimed: 4, Failed: 4, Remaining: 100,
|
|
})
|
|
}
|
|
|
|
func TestEbookMetadataTaskStopsAfterOneAllDeferredBatch(t *testing.T) {
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 4, Deferred: 4, Remaining: 0},
|
|
{Claimed: 4, Enriched: 4, Remaining: 0},
|
|
}}
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
if err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), progress); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
assertNoProgressCircuitBreak(t, enricher, progress, ebooks.EnrichmentRunResult{
|
|
Claimed: 4, Deferred: 4, Remaining: 0,
|
|
})
|
|
}
|
|
|
|
func TestEbookMetadataTaskContinuesAfterMixedBatchWithProgress(t *testing.T) {
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 4, Enriched: 1, Failed: 2, Deferred: 1, Remaining: 2},
|
|
{Claimed: 2, NoMatch: 2, Remaining: 0},
|
|
}}
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
if err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), progress); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if len(enricher.scopes) != 2 {
|
|
t.Fatalf("Run calls = %d, want 2 when a mixed batch made progress", len(enricher.scopes))
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataBackfillStopsAtClaimCap(t *testing.T) {
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "4")
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 2, Enriched: 2, Remaining: 10},
|
|
{Claimed: 2, Enriched: 2, Remaining: 8},
|
|
{Claimed: 2, Enriched: 2, Remaining: 6},
|
|
}}
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
if err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), progress); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if len(enricher.scopes) != 2 {
|
|
t.Fatalf("Run calls = %d, want 2 at claim cap", len(enricher.scopes))
|
|
}
|
|
var result ebooks.EnrichmentRunResult
|
|
if err := json.Unmarshal(progress.results[len(progress.results)-1], &result); err != nil {
|
|
t.Fatalf("result JSON error: %v", err)
|
|
}
|
|
want := ebooks.EnrichmentRunResult{Claimed: 4, Enriched: 4, Remaining: 8}
|
|
if result != want {
|
|
t.Fatalf("result JSON = %+v, want %+v", result, want)
|
|
}
|
|
if got := progress.percents[len(progress.percents)-1]; got >= 100 {
|
|
t.Fatalf("claim-capped progress = %.1f, must not report completion", got)
|
|
}
|
|
message := strings.ToLower(progress.messages[len(progress.messages)-1])
|
|
if !strings.Contains(message, "claim cap") || !strings.Contains(message, "retry later") {
|
|
t.Fatalf("claim-cap progress message = %q", message)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataBackfillPassesRemainingClaimAllowanceToEnricher(t *testing.T) {
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "3")
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 2, Enriched: 2, Remaining: 8},
|
|
{Claimed: 1, Enriched: 1, Remaining: 7},
|
|
}}
|
|
|
|
if err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), &ebookMetadataProgressReporter{}); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if got := enricher.limits; len(got) != 2 || got[0] != 3 || got[1] != 1 {
|
|
t.Fatalf("claim limits = %v, want [3 1]", got)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataBackfillDelaysOnlyBetweenProductiveBatches(t *testing.T) {
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 1, Enriched: 1, Remaining: 2},
|
|
{Claimed: 1, NoMatch: 1, Remaining: 1},
|
|
{Claimed: 1, Enriched: 1, Remaining: 0},
|
|
}}
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
task.batchDelay = time.Second
|
|
var sleeps []time.Duration
|
|
task.sleep = func(_ context.Context, delay time.Duration) error {
|
|
sleeps = append(sleeps, delay)
|
|
return nil
|
|
}
|
|
|
|
if err := task.Execute(context.Background(), &ebookMetadataProgressReporter{}); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if len(sleeps) != 2 || sleeps[0] != time.Second || sleeps[1] != time.Second {
|
|
t.Fatalf("sleeps = %v, want two 1s inter-batch delays", sleeps)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataBackfillCancelsDuringBatchDelay(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 1, Enriched: 1, Remaining: 2},
|
|
{Claimed: 1, Enriched: 1, Remaining: 1},
|
|
}}
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
task.batchDelay = time.Second
|
|
task.sleep = func(ctx context.Context, _ time.Duration) error {
|
|
cancel()
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
|
|
err := task.Execute(ctx, &ebookMetadataProgressReporter{})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Execute() error = %v, want context.Canceled", err)
|
|
}
|
|
if len(enricher.scopes) != 1 {
|
|
t.Fatalf("Run calls = %d, want 1 before cancellation", len(enricher.scopes))
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataBackfillDoesNotDelayPastExecutionBudget(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
|
|
{Claimed: 1, Enriched: 1, Remaining: 2},
|
|
{Claimed: 1, Enriched: 1, Remaining: 1},
|
|
}}
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
task.now = func() time.Time { return now }
|
|
task.budget = 1500 * time.Millisecond
|
|
task.batchDelay = time.Second
|
|
var sleeps int
|
|
task.sleep = func(_ context.Context, delay time.Duration) error {
|
|
sleeps++
|
|
now = now.Add(delay)
|
|
return nil
|
|
}
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
if err := task.Execute(context.Background(), progress); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if sleeps != 1 {
|
|
t.Fatalf("sleep calls = %d, want 1 before remaining budget became too short", sleeps)
|
|
}
|
|
if len(enricher.scopes) != 2 {
|
|
t.Fatalf("Run calls = %d, want 2", len(enricher.scopes))
|
|
}
|
|
message := strings.ToLower(progress.messages[len(progress.messages)-1])
|
|
if !strings.Contains(message, "execution budget") {
|
|
t.Fatalf("budget progress message = %q", message)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataBackfillInvalidNonemptyEnvironmentFailsClosed(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
max string
|
|
delay string
|
|
}{
|
|
{name: "malformed", max: "many", delay: "later"},
|
|
{name: "negative", max: "-5", delay: "-1s"},
|
|
{name: "zero", max: "0", delay: "0"},
|
|
{name: "invalid max only", max: "many", delay: "1s"},
|
|
{name: "invalid delay only", max: "20", delay: "later"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", tc.max)
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", tc.delay)
|
|
enricher := &fakeEbookMetadataEnricher{}
|
|
err := NewBackfillEbookMetadataTask(enricher).Execute(context.Background(), &ebookMetadataProgressReporter{})
|
|
if err == nil || !strings.Contains(err.Error(), "invalid ebook backfill configuration") {
|
|
t.Fatalf("Execute() error = %v, want clear invalid configuration error", err)
|
|
}
|
|
if len(enricher.scopes) != 0 || enricher.readyCountCalls != 0 {
|
|
t.Fatalf("invalid configuration reached queue: runs=%d counts=%d", len(enricher.scopes), enricher.readyCountCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataBackfillUnsetAndValidEnvironment(t *testing.T) {
|
|
t.Run("unset", func(t *testing.T) {
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "")
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", "")
|
|
task := NewBackfillEbookMetadataTask(&fakeEbookMetadataEnricher{})
|
|
if task.configErr != nil || task.maxClaims != 0 || task.batchDelay != 0 {
|
|
t.Fatalf("unset controls = (%d, %s, %v)", task.maxClaims, task.batchDelay, task.configErr)
|
|
}
|
|
})
|
|
t.Run("valid", func(t *testing.T) {
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", " 20 ")
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", " 1s ")
|
|
task := NewBackfillEbookMetadataTask(&fakeEbookMetadataEnricher{})
|
|
if task.configErr != nil || task.maxClaims != 20 || task.batchDelay != time.Second {
|
|
t.Fatalf("valid controls = (%d, %s, %v)", task.maxClaims, task.batchDelay, task.configErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestEbookMetadataSyncIgnoresBackfillCanaryEnvironment(t *testing.T) {
|
|
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "1")
|
|
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", "1h")
|
|
task := NewSyncEbookMetadataTask(&fakeEbookMetadataEnricher{})
|
|
if task.maxClaims != 0 || task.batchDelay != 0 {
|
|
t.Fatalf("sync controls = (%d, %s), want disabled", task.maxClaims, task.batchDelay)
|
|
}
|
|
}
|
|
|
|
func assertNoProgressCircuitBreak(
|
|
t *testing.T,
|
|
enricher *fakeEbookMetadataEnricher,
|
|
progress *ebookMetadataProgressReporter,
|
|
want ebooks.EnrichmentRunResult,
|
|
) {
|
|
t.Helper()
|
|
if len(enricher.scopes) != 1 {
|
|
t.Fatalf("Run calls = %d, want exactly 1", len(enricher.scopes))
|
|
}
|
|
if len(progress.results) == 0 {
|
|
t.Fatal("no result JSON reported")
|
|
}
|
|
var result ebooks.EnrichmentRunResult
|
|
if err := json.Unmarshal(progress.results[len(progress.results)-1], &result); err != nil {
|
|
t.Fatalf("result JSON error: %v", err)
|
|
}
|
|
if result != want {
|
|
t.Fatalf("result JSON = %+v, want %+v", result, want)
|
|
}
|
|
if got := progress.percents[len(progress.percents)-1]; got >= 100 {
|
|
t.Fatalf("circuit-break progress = %.1f, must not report completion", got)
|
|
}
|
|
message := progress.messages[len(progress.messages)-1]
|
|
if !strings.Contains(strings.ToLower(message), "no progress") ||
|
|
!strings.Contains(strings.ToLower(message), "retry later") {
|
|
t.Fatalf("circuit-break progress message = %q", message)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataBackfillUsesLegacyScope(t *testing.T) {
|
|
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{{Remaining: 0}}}
|
|
task := NewBackfillEbookMetadataTask(enricher)
|
|
if err := task.Execute(context.Background(), &ebookMetadataProgressReporter{}); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if len(enricher.scopes) != 1 || enricher.scopes[0] != ebooks.EnrichmentScopeLegacy {
|
|
t.Fatalf("backfill scopes = %#v, want legacy", enricher.scopes)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataTaskStopsBetweenBatchesOnCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
enricher := &fakeEbookMetadataEnricher{
|
|
results: []ebooks.EnrichmentRunResult{{Claimed: 1, Enriched: 1, Remaining: 2}},
|
|
onRun: func(int) {
|
|
cancel()
|
|
},
|
|
}
|
|
err := NewSyncEbookMetadataTask(enricher).Execute(ctx, &ebookMetadataProgressReporter{})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Execute() error = %v, want context.Canceled", err)
|
|
}
|
|
if len(enricher.scopes) != 1 {
|
|
t.Fatalf("Run calls = %d, want 1", len(enricher.scopes))
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataTaskTreatsBudgetAsCleanDeferredCompletion(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
enricher := &fakeEbookMetadataEnricher{
|
|
results: []ebooks.EnrichmentRunResult{{Claimed: 2, Enriched: 2, Remaining: 5}},
|
|
onRun: func(int) {
|
|
now = now.Add(5 * time.Minute)
|
|
},
|
|
}
|
|
task := NewSyncEbookMetadataTask(enricher)
|
|
task.now = func() time.Time { return now }
|
|
task.budget = 4 * time.Minute
|
|
progress := &ebookMetadataProgressReporter{}
|
|
|
|
if err := task.Execute(context.Background(), progress); err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if len(enricher.scopes) != 1 {
|
|
t.Fatalf("Run calls = %d, want 1", len(enricher.scopes))
|
|
}
|
|
var result ebooks.EnrichmentRunResult
|
|
if err := json.Unmarshal(progress.results[len(progress.results)-1], &result); err != nil {
|
|
t.Fatalf("result JSON error: %v", err)
|
|
}
|
|
if result.Remaining != 5 || result.Deferred != 0 {
|
|
t.Fatalf("budget result = %+v, want remaining=5 without double-counting it as deferred", result)
|
|
}
|
|
if got := progress.percents[len(progress.percents)-1]; got >= 100 {
|
|
t.Fatalf("budget-limited progress = %.1f, must remain below 100", got)
|
|
}
|
|
}
|
|
|
|
func TestEbookMetadataTaskWrapsRunError(t *testing.T) {
|
|
enricher := &fakeEbookMetadataEnricher{err: errors.New("boom")}
|
|
err := NewSyncEbookMetadataTask(enricher).Execute(context.Background(), &ebookMetadataProgressReporter{})
|
|
if err == nil || !strings.Contains(err.Error(), "ebook metadata sync") {
|
|
t.Fatalf("Execute() error = %v, want wrapped ebook metadata sync error", err)
|
|
}
|
|
}
|