From 429b75131b5b3cadf692ae2893b2de437efc8e42 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 16:29:22 -0400 Subject: [PATCH] feat(lidarrconfig): typed singleton config wrapper Co-Authored-By: Claude Sonnet 4.6 --- internal/lidarrconfig/service.go | 88 +++++++++++++++++++++++++++ internal/lidarrconfig/service_test.go | 88 +++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 internal/lidarrconfig/service.go create mode 100644 internal/lidarrconfig/service_test.go diff --git a/internal/lidarrconfig/service.go b/internal/lidarrconfig/service.go new file mode 100644 index 00000000..fc047e1c --- /dev/null +++ b/internal/lidarrconfig/service.go @@ -0,0 +1,88 @@ +// Package lidarrconfig is a thin wrapper over the singleton lidarr_config +// row. Get returns a typed Config; Save updates it. Callers branch on +// Config.Enabled — never on raw NULL fields. +package lidarrconfig + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Config is the typed projection of lidarr_config (no NULL fields exposed +// to callers — empty strings / zero ints carry the "unset" meaning). +type Config struct { + Enabled bool + BaseURL string + APIKey string + DefaultQualityProfileID int + DefaultRootFolderPath string +} + +// Service reads and writes the singleton. +type Service struct { + pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } + +func (s *Service) Get(ctx context.Context) (Config, error) { + row, err := dbq.New(s.pool).GetLidarrConfig(ctx) + if err != nil { + return Config{}, fmt.Errorf("lidarrconfig: %w", err) + } + cfg := Config{Enabled: row.Enabled} + if row.BaseUrl != nil { + cfg.BaseURL = *row.BaseUrl + } + if row.ApiKey != nil { + cfg.APIKey = *row.ApiKey + } + if row.DefaultQualityProfileID != nil { + cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID) + } + if row.DefaultRootFolderPath != nil { + cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath + } + return cfg, nil +} + +// Save writes the entire row. Callers pass the full Config they want +// stored — this is not a partial update. +func (s *Service) Save(ctx context.Context, cfg Config) error { + var ( + baseURL = strPtr(cfg.BaseURL) + apiKey = strPtr(cfg.APIKey) + qpID = int32Ptr(cfg.DefaultQualityProfileID) + rootPath = strPtr(cfg.DefaultRootFolderPath) + ) + _, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{ + Enabled: cfg.Enabled, + BaseUrl: baseURL, + ApiKey: apiKey, + DefaultQualityProfileID: qpID, + DefaultRootFolderPath: rootPath, + }) + if err != nil { + return fmt.Errorf("lidarrconfig: %w", err) + } + return nil +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func int32Ptr(i int) *int32 { + if i == 0 { + return nil + } + v := int32(i) + return &v +} diff --git a/internal/lidarrconfig/service_test.go b/internal/lidarrconfig/service_test.go new file mode 100644 index 00000000..627e4be1 --- /dev/null +++ b/internal/lidarrconfig/service_test.go @@ -0,0 +1,88 @@ +package lidarrconfig + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" +) + +func newTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + // Reset singleton to default state before each test by replacing + // the row contents via UPDATE (TRUNCATE would violate the CHECK). + if _, err := pool.Exec(context.Background(), + "UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL, default_quality_profile_id=NULL, default_root_folder_path=NULL WHERE id=1", + ); err != nil { + t.Fatalf("reset: %v", err) + } + return pool +} + +func TestGet_DefaultRowReturnsZeroValueConfig(t *testing.T) { + pool := newTestPool(t) + cfg, err := New(pool).Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if cfg.Enabled || cfg.BaseURL != "" || cfg.APIKey != "" { + t.Errorf("expected zero-value Config, got %+v", cfg) + } +} + +func TestSaveThenGet_RoundTrip(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + want := Config{ + Enabled: true, + BaseURL: "http://lidarr.lan:8686", + APIKey: "secret", + DefaultQualityProfileID: 4, + DefaultRootFolderPath: "/music", + } + if err := s.Save(context.Background(), want); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != want { + t.Errorf("round-trip mismatch:\n got = %+v\nwant = %+v", got, want) + } +} + +func TestSave_EmptyValuesPersistAsNULL(t *testing.T) { + pool := newTestPool(t) + s := New(pool) + if err := s.Save(context.Background(), Config{Enabled: false}); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.BaseURL != "" || got.APIKey != "" || got.DefaultRootFolderPath != "" { + t.Errorf("expected empty strings on round-trip; got %+v", got) + } +}