feat: add configuration management with YAML parsing and validation
Add internal/config package with Config struct (Server, Navidrome, MusicBrainz, Telegram, Scanner sections), LoadConfig function for YAML parsing, config validation (required fields, port range, threshold range), and defaults. Include config.yaml.example matching spec. Update main.go to use real config package instead of placeholders. Add comprehensive tests covering valid configs, defaults, missing files, malformed YAML, and validation boundaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"naviwatcher/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigPath = "config.yaml"
|
const defaultConfigPath = "config.yaml"
|
||||||
@@ -19,7 +20,7 @@ func main() {
|
|||||||
|
|
||||||
log.Println("NaviWatcher starting...")
|
log.Println("NaviWatcher starting...")
|
||||||
|
|
||||||
cfg, err := loadConfig(*configPath)
|
cfg, err := config.LoadConfig(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -45,44 +46,7 @@ func main() {
|
|||||||
log.Println("NaviWatcher stopped.")
|
log.Println("NaviWatcher stopped.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// placeholderConfig mirrors the expected config structure for the skeleton phase.
|
func run(ctx context.Context, cfg *config.Config) error {
|
||||||
// Will be replaced by internal/config.Config in Task 2.
|
|
||||||
type placeholderConfig struct {
|
|
||||||
Server struct {
|
|
||||||
Host string
|
|
||||||
Port int
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadConfig(path string) (*placeholderConfig, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg placeholderConfig
|
|
||||||
if err := parseYAML(data, &cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Server.Host == "" {
|
|
||||||
cfg.Server.Host = "0.0.0.0"
|
|
||||||
}
|
|
||||||
if cfg.Server.Port == 0 {
|
|
||||||
cfg.Server.Port = 8080
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseYAML is a temporary placeholder. Will be replaced by gopkg.in/yaml.v3 in Task 2.
|
|
||||||
func parseYAML(data []byte, out *placeholderConfig) error {
|
|
||||||
// Minimal YAML parsing for skeleton: just handle "server:\n host: ...\n port: ..."
|
|
||||||
// This is intentionally simple and will be replaced by proper YAML parsing.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(ctx context.Context, cfg *placeholderConfig) error {
|
|
||||||
// Main application loop — blocks until context is cancelled.
|
// Main application loop — blocks until context is cancelled.
|
||||||
// Business logic will be added in future tasks.
|
// Business logic will be added in future tasks.
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|||||||
@@ -1,43 +1,62 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"naviwatcher/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadConfig_Defaults(t *testing.T) {
|
func TestDefaultConfigPath(t *testing.T) {
|
||||||
// The skeleton loadConfig applies defaults when host/port are zero-value.
|
if defaultConfigPath != "config.yaml" {
|
||||||
|
t.Errorf("expected default config path 'config.yaml', got %q", defaultConfigPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_GracefulShutdown(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
if err := run(ctx, cfg); err != nil {
|
||||||
|
t.Fatalf("run returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigIntegration(t *testing.T) {
|
||||||
|
// Integration test: write a minimal valid config and load it via config.LoadConfig,
|
||||||
|
// verifying the full path that main() uses.
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
path := filepath.Join(dir, "config.yaml")
|
||||||
if err := os.WriteFile(path, []byte("server:\n host: \"\"\n port: 0\n"), 0644); err != nil {
|
|
||||||
|
yaml := `server:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 9090
|
||||||
|
navidrome:
|
||||||
|
url: "http://localhost:4533"
|
||||||
|
user: "test"
|
||||||
|
password: "test"
|
||||||
|
musicbrainz:
|
||||||
|
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
t.Fatalf("failed to write config: %v", err)
|
t.Fatalf("failed to write config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadConfig(path)
|
cfg, err := config.LoadConfig(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadConfig returned error: %v", err)
|
t.Fatalf("LoadConfig returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Server.Host != "0.0.0.0" {
|
if cfg.Server.Host != "127.0.0.1" {
|
||||||
t.Errorf("expected default host 0.0.0.0, got %q", cfg.Server.Host)
|
t.Errorf("expected host 127.0.0.1, got %q", cfg.Server.Host)
|
||||||
}
|
}
|
||||||
if cfg.Server.Port != 8080 {
|
if cfg.Server.Port != 9090 {
|
||||||
t.Errorf("expected default port 8080, got %d", cfg.Server.Port)
|
t.Errorf("expected port 9090, got %d", cfg.Server.Port)
|
||||||
}
|
}
|
||||||
}
|
if cfg.Navidrome.URL != "http://localhost:4533" {
|
||||||
|
t.Errorf("expected navidrome url http://localhost:4533, got %q", cfg.Navidrome.URL)
|
||||||
func TestLoadConfig_FileNotFound(t *testing.T) {
|
|
||||||
_, err := loadConfig("/nonexistent/path/config.yaml")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for missing file, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYAML_Empty(t *testing.T) {
|
|
||||||
var cfg placeholderConfig
|
|
||||||
err := parseYAML([]byte(""), &cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseYAML returned error for empty input: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
config.yaml.example
Normal file
26
config.yaml.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
# Basic Auth for Web UI access
|
||||||
|
username: "admin"
|
||||||
|
password: "password123"
|
||||||
|
|
||||||
|
navidrome:
|
||||||
|
url: "http://localhost:4533"
|
||||||
|
user: "watcher_service"
|
||||||
|
password: "user_password"
|
||||||
|
|
||||||
|
musicbrainz:
|
||||||
|
user_agent: "NaviWatcher/1.0 ( your-email@example.com )"
|
||||||
|
cache_ttl: 24h
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
enabled: true
|
||||||
|
token: "bot_token"
|
||||||
|
chat_id: "your_chat_id"
|
||||||
|
cron_schedule: "0 10 * * *"
|
||||||
|
|
||||||
|
scanner:
|
||||||
|
fuzzy_threshold: 0.85
|
||||||
|
ignore_bootlegs: true
|
||||||
|
include_compilations: true
|
||||||
@@ -65,14 +65,14 @@ Build the foundation layer of NaviWatcher: a greenfield Go project with zero exi
|
|||||||
- [x] run tests — must pass before task 2
|
- [x] run tests — must pass before task 2
|
||||||
|
|
||||||
### Task 2: Configuration management
|
### Task 2: Configuration management
|
||||||
- [ ] create `internal/config/config.go` with Config struct matching spec (Server, Navidrome, MusicBrainz, Telegram, Scanner sections)
|
- [x] create `internal/config/config.go` with Config struct matching spec (Server, Navidrome, MusicBrainz, Telegram, Scanner sections)
|
||||||
- [ ] implement `LoadConfig(path string) (*Config, error)` function that reads and parses YAML
|
- [x] implement `LoadConfig(path string) (*Config, error)` function that reads and parses YAML
|
||||||
- [ ] implement config validation (required fields, valid port range, valid threshold range 0.0-1.0)
|
- [x] implement config validation (required fields, valid port range, valid threshold range 0.0-1.0)
|
||||||
- [ ] create `config.yaml.example` with all fields filled with placeholder values per spec section 7
|
- [x] create `config.yaml.example` with all fields filled with placeholder values per spec section 7
|
||||||
- [ ] write tests for LoadConfig: valid config file
|
- [x] write tests for LoadConfig: valid config file
|
||||||
- [ ] write tests for LoadConfig: missing file, malformed YAML, invalid values
|
- [x] write tests for LoadConfig: missing file, malformed YAML, invalid values
|
||||||
- [ ] write tests for config validation logic
|
- [x] write tests for config validation logic
|
||||||
- [ ] run tests — must pass before task 3
|
- [x] run tests — must pass before task 3
|
||||||
|
|
||||||
### Task 3: Database layer — schema and migrations
|
### Task 3: Database layer — schema and migrations
|
||||||
- [ ] create `internal/database/database.go` with DB struct and `New(dbPath string) (*DB, error)` constructor
|
- [ ] create `internal/database/database.go` with DB struct and `New(dbPath string) (*DB, error)` constructor
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,3 +1,5 @@
|
|||||||
module naviwatcher
|
module naviwatcher
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
3
go.sum
3
go.sum
@@ -0,0 +1,3 @@
|
|||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
112
internal/config/config.go
Normal file
112
internal/config/config.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the top-level configuration for NaviWatcher.
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Navidrome NavidromeConfig `yaml:"navidrome"`
|
||||||
|
MusicBrainz MusicBrainzConfig `yaml:"musicbrainz"`
|
||||||
|
Telegram TelegramConfig `yaml:"telegram"`
|
||||||
|
Scanner ScannerConfig `yaml:"scanner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerConfig holds HTTP server settings.
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NavidromeConfig holds Subsonic API connection details.
|
||||||
|
type NavidromeConfig struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
User string `yaml:"user"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MusicBrainzConfig holds MusicBrainz API settings.
|
||||||
|
type MusicBrainzConfig struct {
|
||||||
|
UserAgent string `yaml:"user_agent"`
|
||||||
|
CacheTTL time.Duration `yaml:"cache_ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramConfig holds Telegram bot notification settings.
|
||||||
|
type TelegramConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
ChatID string `yaml:"chat_id"`
|
||||||
|
CronSchedule string `yaml:"cron_schedule"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScannerConfig holds scanner engine parameters.
|
||||||
|
type ScannerConfig struct {
|
||||||
|
FuzzyThreshold float64 `yaml:"fuzzy_threshold"`
|
||||||
|
IgnoreBootlegs bool `yaml:"ignore_bootlegs"`
|
||||||
|
IncludeCompilations bool `yaml:"include_compilations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig reads a YAML file from path, parses it, applies defaults,
|
||||||
|
// and validates the configuration.
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
|
||||||
|
if err := validate(&cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDefaults sets zero-value defaults for optional fields.
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.Server.Host == "" {
|
||||||
|
cfg.Server.Host = "0.0.0.0"
|
||||||
|
}
|
||||||
|
if cfg.Server.Port == 0 {
|
||||||
|
cfg.Server.Port = 8080
|
||||||
|
}
|
||||||
|
if cfg.Scanner.FuzzyThreshold == 0 {
|
||||||
|
cfg.Scanner.FuzzyThreshold = 0.85
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate checks that required fields are set and values are within acceptable ranges.
|
||||||
|
func validate(cfg *Config) error {
|
||||||
|
if cfg.Navidrome.URL == "" {
|
||||||
|
return fmt.Errorf("navidrome.url is required")
|
||||||
|
}
|
||||||
|
if cfg.Navidrome.User == "" {
|
||||||
|
return fmt.Errorf("navidrome.user is required")
|
||||||
|
}
|
||||||
|
if cfg.Navidrome.Password == "" {
|
||||||
|
return fmt.Errorf("navidrome.password is required")
|
||||||
|
}
|
||||||
|
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
|
||||||
|
return fmt.Errorf("server.port must be between 1 and 65535, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if cfg.Scanner.FuzzyThreshold < 0.0 || cfg.Scanner.FuzzyThreshold > 1.0 {
|
||||||
|
return fmt.Errorf("scanner.fuzzy_threshold must be between 0.0 and 1.0, got %f", cfg.Scanner.FuzzyThreshold)
|
||||||
|
}
|
||||||
|
if cfg.MusicBrainz.UserAgent == "" {
|
||||||
|
return fmt.Errorf("musicbrainz.user_agent is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
366
internal/config/config_test.go
Normal file
366
internal/config/config_test.go
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadConfig_Valid(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := `
|
||||||
|
server:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 9090
|
||||||
|
username: "testuser"
|
||||||
|
password: "testpass"
|
||||||
|
|
||||||
|
navidrome:
|
||||||
|
url: "http://navidrome:4533"
|
||||||
|
user: "service"
|
||||||
|
password: "secret"
|
||||||
|
|
||||||
|
musicbrainz:
|
||||||
|
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||||
|
cache_ttl: 24h
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
enabled: true
|
||||||
|
token: "abc123"
|
||||||
|
chat: "chat456"
|
||||||
|
cron_schedule: "0 10 * * *"
|
||||||
|
|
||||||
|
scanner:
|
||||||
|
fuzzy_threshold: 0.9
|
||||||
|
ignore_bootlegs: true
|
||||||
|
include_compilations: false
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Server.Host != "127.0.0.1" {
|
||||||
|
t.Errorf("expected host 127.0.0.1, got %s", cfg.Server.Host)
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 9090 {
|
||||||
|
t.Errorf("expected port 9090, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if cfg.Server.Username != "testuser" {
|
||||||
|
t.Errorf("expected username testuser, got %s", cfg.Server.Username)
|
||||||
|
}
|
||||||
|
if cfg.Navidrome.URL != "http://navidrome:4533" {
|
||||||
|
t.Errorf("expected navidrome url http://navidrome:4533, got %s", cfg.Navidrome.URL)
|
||||||
|
}
|
||||||
|
if cfg.Navidrome.User != "service" {
|
||||||
|
t.Errorf("expected navidrome user service, got %s", cfg.Navidrome.User)
|
||||||
|
}
|
||||||
|
if cfg.Scanner.FuzzyThreshold != 0.9 {
|
||||||
|
t.Errorf("expected fuzzy_threshold 0.9, got %f", cfg.Scanner.FuzzyThreshold)
|
||||||
|
}
|
||||||
|
if !cfg.Scanner.IgnoreBootlegs {
|
||||||
|
t.Error("expected ignore_bootlegs true")
|
||||||
|
}
|
||||||
|
if cfg.Scanner.IncludeCompilations {
|
||||||
|
t.Error("expected include_compilations false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_Defaults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := `
|
||||||
|
navidrome:
|
||||||
|
url: "http://localhost:4533"
|
||||||
|
user: "u"
|
||||||
|
password: "p"
|
||||||
|
|
||||||
|
musicbrainz:
|
||||||
|
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Server.Host != "0.0.0.0" {
|
||||||
|
t.Errorf("expected default host 0.0.0.0, got %s", cfg.Server.Host)
|
||||||
|
}
|
||||||
|
if cfg.Server.Port != 8080 {
|
||||||
|
t.Errorf("expected default port 8080, got %d", cfg.Server.Port)
|
||||||
|
}
|
||||||
|
if cfg.Scanner.FuzzyThreshold != 0.85 {
|
||||||
|
t.Errorf("expected default fuzzy_threshold 0.85, got %f", cfg.Scanner.FuzzyThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_MissingFile(t *testing.T) {
|
||||||
|
_, err := LoadConfig("/nonexistent/path/config.yaml")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_MalformedYAML(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := `
|
||||||
|
server:
|
||||||
|
host: [invalid
|
||||||
|
yaml: {broken
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for malformed YAML, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_InvalidPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
port int
|
||||||
|
}{
|
||||||
|
{"port zero with explicit 0 and no default override", 0},
|
||||||
|
{"port too high", 70000},
|
||||||
|
{"port negative", -1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := `
|
||||||
|
navidrome:
|
||||||
|
url: "http://localhost:4533"
|
||||||
|
user: "u"
|
||||||
|
password: "p"
|
||||||
|
|
||||||
|
musicbrainz:
|
||||||
|
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: ` + string(rune('0'+tt.port%10)) + `
|
||||||
|
`
|
||||||
|
// Use Sprintf for proper port formatting
|
||||||
|
yaml = buildConfigWithPort(path, tt.port)
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if tt.port == 0 {
|
||||||
|
// port 0 gets defaulted to 8080, which is valid
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("port 0 should get default, got error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for port %d, got nil", tt.port)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildConfigWithPort(path string, port int) string {
|
||||||
|
return "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nserver:\n port: " + fmt.Sprintf("%d", port) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_InvalidThreshold(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
threshold float64
|
||||||
|
}{
|
||||||
|
{"threshold too high", 1.5},
|
||||||
|
{"threshold negative", -0.1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nscanner:\n fuzzy_threshold: " + fmt.Sprintf("%.1f", tt.threshold) + "\n"
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for threshold %f, got nil", tt.threshold)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_MissingNavidromeURL(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := `
|
||||||
|
navidrome:
|
||||||
|
user: "u"
|
||||||
|
password: "p"
|
||||||
|
|
||||||
|
musicbrainz:
|
||||||
|
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing navidrome.url, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_MissingNavidromeUser(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := `
|
||||||
|
navidrome:
|
||||||
|
url: "http://localhost:4533"
|
||||||
|
password: "p"
|
||||||
|
|
||||||
|
musicbrainz:
|
||||||
|
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing navidrome.user, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_MissingNavidromePassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := `
|
||||||
|
navidrome:
|
||||||
|
url: "http://localhost:4533"
|
||||||
|
user: "u"
|
||||||
|
|
||||||
|
musicbrainz:
|
||||||
|
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing navidrome.password, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_MissingMusicBrainzUserAgent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := `
|
||||||
|
navidrome:
|
||||||
|
url: "http://localhost:4533"
|
||||||
|
user: "u"
|
||||||
|
password: "p"
|
||||||
|
|
||||||
|
musicbrainz:
|
||||||
|
cache_ttl: 24h
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing musicbrainz.user_agent, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_BoundaryThreshold(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
threshold float64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"threshold 0.0", 0.0, false},
|
||||||
|
{"threshold 1.0", 1.0, false},
|
||||||
|
{"threshold 0.5", 0.5, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nscanner:\n fuzzy_threshold: " + fmt.Sprintf("%.2f", tt.threshold) + "\n"
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Fatalf("expected error for threshold %f, got nil", tt.threshold)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error for threshold %f: %v", tt.threshold, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_BoundaryPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
port int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"port 1", 1, false},
|
||||||
|
{"port 65535", 65535, false},
|
||||||
|
{"port 8080", 8080, false},
|
||||||
|
{"port 65536", 65536, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
|
||||||
|
yaml := buildConfigWithPort(path, tt.port)
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Fatalf("expected error for port %d, got nil", tt.port)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error for port %d: %v", tt.port, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user