feat: add foundation layer (Go module, config, database, Docker)
Squashed commits from foundation-layer branch: - Initialize Go module and project skeleton (cmd/naviwatcher/main.go) - Add configuration management with YAML parsing and validation - Add database layer with schema migrations (artist_settings, external_releases, notifications_sent) - Add CRUD operations for artist_settings, external_releases, notifications_sent - Add Docker setup with multi-stage build and docker-compose - Verify acceptance criteria (tests, vet, fmt) - Update README.md with build/run/test instructions - Fix: filter ignored releases in GetUnnotifiedReleases (spec compliance) - Fix: add FK constraint on notifications_sent.rgid - Fix: add config.yaml to .gitignore (security) - Fix: run Docker container as non-root user - Fix: pin alpine:3.21 instead of alpine:latest - Fix: wrap migrations in transactions for atomicity All 49 tests pass, go vet clean, Docker image builds successfully.
This commit is contained in:
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
|
||||
}
|
||||
348
internal/config/config_test.go
Normal file
348
internal/config/config_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
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_id: "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.Telegram.ChatID != "chat456" {
|
||||
t.Errorf("expected telegram chat_id chat456, got %s", cfg.Telegram.ChatID)
|
||||
}
|
||||
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 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 := buildConfigWithPort(tt.port)
|
||||
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 port %d, got nil", tt.port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfigWithPort(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(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