test(artwork): convert non-synctest timing tests to Ginkgo specs

TestArtworkBackoffSchedule and TestArtworkWorkerRunNoLeak needed no real
*testing.T (no synctest), so move them into worker_test.go as Ginkgo
specs. TestArtworkBreakerHalfOpen stays plain since testing/synctest
requires a real *testing.T, matching core/scrobbler's precedent.
This commit is contained in:
Deluan 2026-07-22 15:28:54 -04:00
parent 55608b2d20
commit ba2290af6d
2 changed files with 56 additions and 58 deletions

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/goleak"
)
// reenqueueOnDequeue simulates a concurrent scan Enqueue between DequeueBatch and the
@ -304,5 +305,58 @@ var _ = Describe("Worker", func() {
cancel()
Eventually(done, time.Second).Should(Receive(BeNil()))
})
It("does not leak goroutines after Run exits", func() {
DeferCleanup(configtest.SetupConfig())
ignore := goleak.IgnoreCurrent()
DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) })
localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()}
lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), &fakeExternalProvider{}, tests.NewMockFFmpeg(""))
runCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- lw.Run(runCtx) }()
time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select
cancel()
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
})
})
})
var _ = Describe("backoff", func() {
It("returns the expected schedule with no jitter", func() {
for _, c := range []struct {
attempts int
want time.Duration
}{
{0, 5 * time.Minute},
{1, 20 * time.Minute},
{2, 80 * time.Minute},
{3, 320 * time.Minute},
{4, 1280 * time.Minute},
{5, 48 * time.Hour},
{6, 48 * time.Hour},
} {
Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts)
}
})
It("applies jitter proportionally", func() {
base := backoffFor(2, 0)
Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2)))
Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8)))
})
It("keeps random jitter within +/-20%", func() {
lo := time.Duration(float64(320*time.Minute) * 0.8)
hi := time.Duration(float64(320*time.Minute) * 1.2)
for range 200 {
d := backoff(3)
Expect(d).To(BeNumerically(">=", lo))
Expect(d).To(BeNumerically("<=", hi))
}
})
})

View File

@ -1,48 +1,16 @@
package artwork
import (
"context"
"errors"
"testing"
"testing/synctest"
"time"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/gomega"
"go.uber.org/goleak"
)
func TestArtworkBackoffSchedule(t *testing.T) {
g := NewWithT(t)
for _, c := range []struct {
attempts int
want time.Duration
}{
{0, 5 * time.Minute},
{1, 20 * time.Minute},
{2, 80 * time.Minute},
{3, 320 * time.Minute},
{4, 1280 * time.Minute},
{5, 48 * time.Hour},
{6, 48 * time.Hour},
} {
g.Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts)
}
base := backoffFor(2, 0)
g.Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2)))
g.Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8)))
lo := time.Duration(float64(320*time.Minute) * 0.8)
hi := time.Duration(float64(320*time.Minute) * 1.2)
for range 200 {
d := backoff(3)
g.Expect(d).To(BeNumerically(">=", lo))
g.Expect(d).To(BeNumerically("<=", hi))
}
}
// Drives the real breaker state machine with the fake clock. Plain test: testing/synctest
// needs a *testing.T, which Ginkgo doesn't give.
func TestArtworkBreakerHalfOpen(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
@ -69,27 +37,3 @@ func TestArtworkBreakerHalfOpen(t *testing.T) {
g.Expect(b.allow()).To(BeTrue())
})
}
func TestArtworkWorkerRunNoLeak(t *testing.T) {
ignore := goleak.IgnoreCurrent()
defer goleak.VerifyNone(t, ignore)
t.Cleanup(configtest.SetupConfig())
ds := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()}
w := NewWorker(ds, NewImageStore(t.TempDir()), &fakeExternalProvider{}, tests.NewMockFFmpeg(""))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- w.Run(ctx) }()
time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Run returned error on cancel: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run did not exit after context cancel")
}
}