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:
2026-05-20 16:11:11 +03:00
parent 001f9ce691
commit 735ff0828e
21 changed files with 2700 additions and 5 deletions

View 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)
}
})
}
}