Files

485 lines
17 KiB
Go
Raw Permalink Normal View History

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)
}
}
2026-07-19 16:25:18 +02:00
func TestEbookMetadataTaskStopsAfterOneAllFailedBatch(t *testing.T) {
2026-07-19 16:36:30 +02:00
t.Setenv("SILO_EBOOK_BACKFILL_MAX_CLAIMS", "1")
t.Setenv("SILO_EBOOK_BACKFILL_BATCH_DELAY", "1h")
2026-07-19 16:25:18 +02:00
enricher := &fakeEbookMetadataEnricher{results: []ebooks.EnrichmentRunResult{
{Claimed: 4, Failed: 4, Remaining: 100},
{Claimed: 4, Enriched: 4, Remaining: 96},
}}
progress := &ebookMetadataProgressReporter{}
2026-07-19 16:36:30 +02:00
task := NewBackfillEbookMetadataTask(enricher)
task.sleep = func(context.Context, time.Duration) error {
t.Fatal("no-progress batch must circuit-break before pacing")
return nil
}
2026-07-19 16:25:18 +02:00
2026-07-19 16:36:30 +02:00
if err := task.Execute(context.Background(), progress); err != nil {
2026-07-19 16:25:18 +02:00
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))
}
}
2026-07-19 16:36:30 +02:00
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}
2026-07-19 16:36:30 +02:00
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)
}
}
2026-07-19 16:36:30 +02:00
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) {
2026-07-19 16:36:30 +02:00
for _, tc := range []struct {
name string
max string
delay string
2026-07-19 16:36:30 +02:00
}{
{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"},
2026-07-19 16:36:30 +02:00
} {
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)
2026-07-19 16:36:30 +02:00
}
})
}
}
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)
}
})
}
2026-07-19 16:36:30 +02:00
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)
}
}
2026-07-19 16:25:18 +02:00
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)
}
}