test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed singleton (migration 0061), edited from a card on the Duplicates page and shared live with the scanner, the backfill and the duplicate sweep through one service instance, so a save needs no restart. The length is the knob that can silently break the library: prints taken at two lengths never match. Each track_fingerprints row now records the length it was taken at, and every reader filters on the current one — the backfill treats another length as stale, the gauge counts it pending, the sweep never streams it. Equivalent to a version bump, except that setting the length back makes rows not yet redone current again. The card warns before a length change re-fingerprints the library. Off stops every decode: the scan takes only the stream hash (a demux, and what recognises a moved file) and stores nothing, dropping a changed file's stale row; the backfill idles. A save also makes a sweep due, since a new threshold or length changes what the same prints group into, and the sweep interval gains slack so an hourly interval on an hourly tick doesn't skip every other tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
147 lines
6.2 KiB
Go
147 lines
6.2 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestValidateFingerprintSettings(t *testing.T) {
|
|
with := func(edit func(*FingerprintSettings)) FingerprintSettings {
|
|
s := DefaultFingerprintSettings
|
|
edit(&s)
|
|
return s
|
|
}
|
|
valid := map[string]FingerprintSettings{
|
|
"defaults": DefaultFingerprintSettings,
|
|
"shortest length": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = minChromaprintLengthSec }),
|
|
"longest length": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = maxChromaprintLengthSec }),
|
|
"strictest match": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = minAcousticMaxBitErrorRate }),
|
|
"loosest match": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = maxAcousticMaxBitErrorRate }),
|
|
"fewest at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = minBackfillConcurrency }),
|
|
"most at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = maxBackfillConcurrency }),
|
|
"shortest interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = minSweepIntervalHours }),
|
|
"longest interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = maxSweepIntervalHours }),
|
|
"switched off": with(func(s *FingerprintSettings) { s.Enabled = false }),
|
|
}
|
|
for name, s := range valid {
|
|
if err := validateFingerprintSettings(s); err != nil {
|
|
t.Errorf("%s: rejected: %v", name, err)
|
|
}
|
|
}
|
|
invalid := map[string]FingerprintSettings{
|
|
"length too short": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = minChromaprintLengthSec - 1 }),
|
|
"length too long": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = maxChromaprintLengthSec + 1 }),
|
|
"match too strict": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = minAcousticMaxBitErrorRate - 0.001 }),
|
|
"match too loose": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = maxAcousticMaxBitErrorRate + 0.001 }),
|
|
"match not a number": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = math.NaN() }),
|
|
"none at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = 0 }),
|
|
"too many at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = maxBackfillConcurrency + 1 }),
|
|
"no interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = 0 }),
|
|
"interval too long": with(func(s *FingerprintSettings) { s.SweepIntervalHours = maxSweepIntervalHours + 1 }),
|
|
}
|
|
for name, s := range invalid {
|
|
if err := validateFingerprintSettings(s); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
|
|
t.Errorf("%s: err = %v, want ErrFingerprintSettingOutOfRange", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFingerprintSettingsService_NilServesDefaults(t *testing.T) {
|
|
var s *FingerprintSettingsService
|
|
if got := s.Get(); got != DefaultFingerprintSettings {
|
|
t.Fatalf("nil service Get = %+v, want the defaults", got)
|
|
}
|
|
// Validation still runs first, so a bad value is named rather than hidden
|
|
// behind the missing service.
|
|
bad := DefaultFingerprintSettings
|
|
bad.BackfillConcurrency = 0
|
|
if _, err := s.Set(context.Background(), bad); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
|
|
t.Fatalf("nil service Set of a bad value: err = %v, want ErrFingerprintSettingOutOfRange", err)
|
|
}
|
|
}
|
|
|
|
func TestFingerprintSettingsService_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
reload := func(step string) FingerprintSettings {
|
|
t.Helper()
|
|
fresh, err := NewFingerprintSettingsService(ctx, pool)
|
|
if err != nil {
|
|
t.Fatalf("%s: load: %v", step, err)
|
|
}
|
|
return fresh.Get()
|
|
}
|
|
withoutTime := func(s FingerprintSettings) FingerprintSettings {
|
|
s.UpdatedAt = time.Time{}
|
|
return s
|
|
}
|
|
|
|
svc, err := NewFingerprintSettingsService(ctx, pool)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
// ResetDB puts every column back to its migration default, so this pins the
|
|
// Go defaults to migration 0061's. Were they to drift, a database that could
|
|
// not be read would fingerprint differently from one that could.
|
|
loaded := svc.Get()
|
|
if loaded.UpdatedAt.IsZero() {
|
|
t.Fatal("loaded settings carry no updated_at")
|
|
}
|
|
if withoutTime(loaded) != DefaultFingerprintSettings {
|
|
t.Fatalf("stored defaults = %+v, want the Go defaults %+v", withoutTime(loaded), DefaultFingerprintSettings)
|
|
}
|
|
|
|
// Every bound the service accepts, the table accepts too. A CHECK tighter
|
|
// than validate would turn a value the card allows into a 500.
|
|
lowest := FingerprintSettings{
|
|
Enabled: false,
|
|
ChromaprintLengthSec: minChromaprintLengthSec,
|
|
AcousticMaxBitErrorRate: minAcousticMaxBitErrorRate,
|
|
BackfillConcurrency: minBackfillConcurrency,
|
|
SweepIntervalHours: minSweepIntervalHours,
|
|
}
|
|
highest := FingerprintSettings{
|
|
Enabled: true,
|
|
ChromaprintLengthSec: maxChromaprintLengthSec,
|
|
AcousticMaxBitErrorRate: maxAcousticMaxBitErrorRate,
|
|
BackfillConcurrency: maxBackfillConcurrency,
|
|
SweepIntervalHours: maxSweepIntervalHours,
|
|
}
|
|
for _, step := range []struct {
|
|
name string
|
|
want FingerprintSettings
|
|
}{{"lowest", lowest}, {"highest", highest}} {
|
|
saved, err := svc.Set(ctx, step.want)
|
|
if err != nil {
|
|
t.Fatalf("%s: save: %v", step.name, err)
|
|
}
|
|
// A save must move updated_at forward: it is what makes a sweep due.
|
|
if !saved.UpdatedAt.After(loaded.UpdatedAt) {
|
|
t.Fatalf("%s: updated_at %v did not move past %v", step.name, saved.UpdatedAt, loaded.UpdatedAt)
|
|
}
|
|
if withoutTime(saved) != step.want || withoutTime(svc.Get()) != step.want {
|
|
t.Fatalf("%s: saved %+v, cached %+v, want %+v", step.name, saved, svc.Get(), step.want)
|
|
}
|
|
if got := withoutTime(reload(step.name)); got != step.want {
|
|
t.Fatalf("%s: table holds %+v, want %+v", step.name, got, step.want)
|
|
}
|
|
}
|
|
|
|
// An out-of-range save changes nothing, in the cache or the table.
|
|
before := svc.Get()
|
|
bad := highest
|
|
bad.ChromaprintLengthSec = maxChromaprintLengthSec + 1
|
|
if _, err := svc.Set(ctx, bad); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
|
|
t.Fatalf("out-of-range save: err = %v, want ErrFingerprintSettingOutOfRange", err)
|
|
}
|
|
if svc.Get() != before {
|
|
t.Fatalf("out-of-range save changed the cache to %+v", svc.Get())
|
|
}
|
|
if got := reload("after out-of-range save"); got != before {
|
|
t.Fatalf("out-of-range save changed the table to %+v", got)
|
|
}
|
|
}
|