diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..336dd5a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +.DS_Store +naviwatcher +naviwatcher-linux +naviwatcher-mac +naviwatcher.exe +data/ +.claude/ +.ralphex/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbce990 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +naviwatcher +naviwatcher-linux +naviwatcher-mac +naviwatcher.exe +config.yaml +data/ +coverage.out diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3c5948d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=1 GOOS=linux go build -o naviwatcher ./cmd/naviwatcher + +# Runtime stage +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates sqlite-libs && \ + adduser -D -g '' appuser + +WORKDIR /app + +COPY --from=builder /app/naviwatcher . + +RUN chown appuser:appuser /app + +EXPOSE 8080 + +VOLUME ["/app/data"] + +USER appuser + +ENTRYPOINT ["./naviwatcher"] +CMD ["-config=/app/data/config.yaml"] diff --git a/README.md b/README.md index cc2d0a4..d5ca75e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ NaviWatcher is an autonomous service daemon that monitors your Navidrome music c ### Technology Stack -- **Language:** Go 1.21+ +- **Language:** Go 1.25+ - **Database:** SQLite 3 - **HTTP Server:** Go standard library (`net/http` + `html/template`) - **Key dependencies:** @@ -69,12 +69,12 @@ server: host: "0.0.0.0" port: 8080 username: "admin" - password: "password123" + password: "CHANGE_ME" navidrome: url: "http://localhost:4533" user: "watcher_service" - password: "user_password" + password: "CHANGE_ME" musicbrainz: user_agent: "NaviWatcher/1.0 ( mail@example.com )" @@ -176,12 +176,12 @@ server: host: "0.0.0.0" port: 8080 username: "admin" - password: "password123" + password: "CHANGE_ME" navidrome: url: "http://localhost:4533" user: "watcher_service" - password: "user_password" + password: "CHANGE_ME" musicbrainz: user_agent: "NaviWatcher/1.0 ( mail@example.com )" diff --git a/cmd/naviwatcher/main.go b/cmd/naviwatcher/main.go new file mode 100644 index 0000000..477cf86 --- /dev/null +++ b/cmd/naviwatcher/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "syscall" + + "naviwatcher/internal/config" +) + +const defaultConfigPath = "config.yaml" + +func main() { + configPath := flag.String("config", defaultConfigPath, "Path to config file") + flag.Parse() + + log.Println("NaviWatcher starting...") + + cfg, err := config.LoadConfig(*configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + log.Printf("Config loaded from %s (server: %s:%d)", *configPath, cfg.Server.Host, cfg.Server.Port) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigCh + log.Printf("Received signal %v, shutting down...", sig) + cancel() + }() + + if err := run(ctx, cfg); err != nil { + log.Fatalf("Application error: %v", err) + } + + log.Println("NaviWatcher stopped.") +} + +func run(ctx context.Context, cfg *config.Config) error { + // Main application loop — blocks until context is cancelled. + // Business logic will be added in future tasks. + <-ctx.Done() + + return nil +} diff --git a/cmd/naviwatcher/main_test.go b/cmd/naviwatcher/main_test.go new file mode 100644 index 0000000..d99d7b5 --- /dev/null +++ b/cmd/naviwatcher/main_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "naviwatcher/internal/config" +) + +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() + path := filepath.Join(dir, "config.yaml") + + 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) + } + + cfg, err := config.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 %q", cfg.Server.Host) + } + if cfg.Server.Port != 9090 { + 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) + } +} diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..95cc5ae --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,26 @@ +server: + host: "0.0.0.0" + port: 8080 + # Basic Auth for Web UI access + username: "admin" + password: "CHANGE_ME" + +navidrome: + url: "http://localhost:4533" + user: "watcher_service" + password: "CHANGE_ME" + +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..474105a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + naviwatcher: + build: + context: . + dockerfile: Dockerfile + container_name: naviwatcher + ports: + - "8080:8080" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/docs/plans/2026-05-20-foundation-layer.md b/docs/plans/2026-05-20-foundation-layer.md new file mode 100644 index 0000000..25f7afa --- /dev/null +++ b/docs/plans/2026-05-20-foundation-layer.md @@ -0,0 +1,261 @@ +# Foundation Layer + +## Overview +Build the foundation layer of NaviWatcher: a greenfield Go project with zero existing code. This plan covers project initialization, configuration management, SQLite database layer, and Docker deployment setup. These are the building blocks that all future modules (Navidrome client, MusicBrainz provider, Scanner, Notifier, Web UI) will depend on. + +**Problem it solves:** Establishes the project skeleton, dependency management, configuration parsing, persistent storage, and containerized deployment — everything needed before any business logic can be built. + +**How it integrates:** This is the first plan for the project. Future plans will build on this foundation: +- Plan 2: Navidrome client (depends on config + DB) +- Plan 3: MusicBrainz provider (depends on config + DB) +- Plan 4: Scanner engine (depends on Navidrome + MusicBrainz + DB) +- Plan 5: Notifier (depends on DB + config) +- Plan 6: Web UI (depends on DB + config) + +## Context (from discovery) +- **Files/components involved:** All files are new — this is a greenfield project +- **Related patterns found:** Specification defines a 6-module architecture with YAML config, SQLite storage, and Docker deployment +- **Dependencies identified:** + - `github.com/mattn/go-sqlite3` — SQLite 3 driver + - `gopkg.in/yaml.v3` — YAML config parsing + - Go 1.21+ required +- **Current state:** Only docs exist (CLAUDE.md, README.md, License.md, docs/Specification.md). No Go code, no go.mod, no config files. + +## Development Approach +- **Testing approach:** Regular (code first, then tests) +- Complete each task fully before moving to the next +- Make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** for code changes in that task + - tests are not optional — they are a required part of the checklist + - write unit tests for new functions/methods + - write unit tests for modified functions/methods + - add new test cases for new code paths + - update existing test cases if behavior changes + - tests cover both success and error scenarios +- **CRITICAL: all tests must pass before starting next task** — no exceptions +- **CRITICAL: update this plan file when scope changes during implementation** +- Run tests after each change +- Maintain backward compatibility + +## Testing Strategy +- **Unit tests**: required for every task (see Development Approach above) +- **E2E tests**: not applicable for foundation layer (no UI yet) + +## Progress Tracking +- Mark completed items with `[x]` immediately when done +- Add newly discovered tasks with ➕ prefix +- Document issues/blockers with ⚠️ prefix +- Update plan if implementation deviates from original scope +- Keep plan in sync with actual work done + +## What Goes Where +- **Implementation Steps** (`[ ]` checkboxes): tasks achievable within this codebase — code changes, tests, documentation updates +- **Post-Completion** (no checkboxes): items requiring external action — manual testing, deployment verification, third-party checks + +## Implementation Steps + +### Task 1: Initialize Go module and project skeleton +- [x] run `go mod init naviwatcher` in project root +- [x] create directory structure: `internal/config/`, `internal/database/`, `cmd/naviwatcher/` +- [x] create `cmd/naviwatcher/main.go` with basic entry point (parse flags, load config placeholder, graceful shutdown skeleton) +- [x] add dependencies: `go get github.com/mattn/go-sqlite3`, `go get gopkg.in/yaml.v3` +- [x] run `go mod tidy` +- [x] verify the project builds: `go build -o naviwatcher ./cmd/naviwatcher` +- [x] write tests for main.go flag parsing (if applicable) +- [x] run tests — must pass before task 2 + +### Task 2: Configuration management +- [x] create `internal/config/config.go` with Config struct matching spec (Server, Navidrome, MusicBrainz, Telegram, Scanner sections) +- [x] implement `LoadConfig(path string) (*Config, error)` function that reads and parses YAML +- [x] implement config validation (required fields, valid port range, valid threshold range 0.0-1.0) +- [x] create `config.yaml.example` with all fields filled with placeholder values per spec section 7 +- [x] write tests for LoadConfig: valid config file +- [x] write tests for LoadConfig: missing file, malformed YAML, invalid values +- [x] write tests for config validation logic +- [x] run tests — must pass before task 3 + +### Task 3: Database layer — schema and migrations +- [x] create `internal/database/database.go` with DB struct and `New(dbPath string) (*DB, error)` constructor +- [x] implement schema migration system (versioned migrations table + ordered migration files or functions) +- [x] create migration 001: `artist_settings` table (id, name, ignore_singles, ignore_compilations, monitored) +- [x] create migration 002: `external_releases` table (rgid PK, artist_id, title, type, release_date, is_ignored) +- [x] create migration 003: `notifications_sent` table (rgid FK, sent_at) +- [x] implement `Close()` method with proper connection cleanup +- [x] write tests for database initialization and schema creation (use in-memory SQLite `:memory:`) +- [x] write tests for migration idempotency (running migrations twice should not fail) +- [x] write tests for Close() behavior +- [x] run tests — must pass before task 4 + +### Task 4: Database layer — CRUD operations for artist_settings +- [x] implement `GetArtistSettings(db *DB, id string) (*ArtistSettings, error)` +- [x] implement `SaveArtistSettings(db *DB, settings *ArtistSettings) error` +- [x] implement `GetAllArtistSettings(db *DB) ([]ArtistSettings, error)` +- [x] implement `UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) error` +- [x] write tests for GetArtistSettings (found and not found cases) +- [x] write tests for SaveArtistSettings (insert and update) +- [x] write tests for GetAllArtistSettings (empty and populated) +- [x] run tests — must pass before task 5 + +### Task 5: Database layer — CRUD operations for external_releases +- [x] implement `GetExternalRelease(db *DB, rgid string) (*ExternalRelease, error)` +- [x] implement `SaveExternalRelease(db *DB, release *ExternalRelease) error` +- [x] implement `GetExternalReleasesByArtist(db *DB, artistID string) ([]ExternalRelease, error)` +- [x] implement `GetIgnoredReleases(db *DB) ([]ExternalRelease, error)` +- [x] implement `SetReleaseIgnored(db *DB, rgid string, ignored bool) error` +- [x] write tests for all CRUD operations (success and error cases) +- [x] write tests for cache TTL logic (if implemented at this layer) +- [x] run tests — must pass before task 6 + +### Task 6: Database layer — CRUD operations for notifications_sent +- [x] implement `MarkNotificationSent(db *DB, rgid string) error` +- [x] implement `IsNotificationSent(db *DB, rgid string) (bool, error)` +- [x] implement `GetUnnotifiedReleases(db *DB) ([]ExternalRelease, error)` — joins external_releases with notifications_sent to find unsent +- [x] write tests for MarkNotificationSent and IsNotificationSent +- [x] write tests for GetUnnotifiedReleases (with and without existing notifications) +- [x] run tests — must pass before task 7 + +### Task 7: Docker setup +- [x] create `Dockerfile` with multi-stage build: build stage (golang:1.25-alpine) + runtime stage (alpine:latest) +- [x] configure Dockerfile to copy config, build binary, expose port, set entrypoint +- [x] create `docker-compose.yml` with naviwatcher service, volume for SQLite DB and config +- [x] create `.dockerignore` file +- [x] verify Docker image builds: `docker compose build` +- [x] verify container starts and health check passes (container starts, loads config, runs successfully) +- [x] run full test suite — must pass before task 8 + +### Task 8: Verify acceptance criteria +- [x] verify Go module builds cleanly with `go build` +- [x] verify config loads and validates from YAML file +- [x] verify all 3 database tables are created via migrations +- [x] verify all CRUD operations work against in-memory SQLite +- [x] verify Docker image builds and container starts +- [x] run full test suite: `go test ./... -v` — all must pass +- [x] run `go vet ./...` — no issues +- [x] run `go fmt ./...` — no formatting issues +- [x] verify test coverage is reasonable for foundation layer (70%+) + +### Task 9: Final documentation update +- [x] update README.md with current build/run/test instructions (replace placeholder commands) +- [x] verify config.yaml.example is complete and matches spec +- [x] document any deviations from specification in this plan — no deviations found; module path is `naviwatcher` (local), Go 1.25.1 exceeds 1.21+ requirement + +*Note: ralphex automatically moves completed plans to `docs/plans/completed/`* + +## Technical Details + +### Config Struct (Go) +```go +type Config struct { + Server ServerConfig `yaml:"server"` + Navidrome NavidromeConfig `yaml:"navidrome"` + MusicBrainz MusicBrainzConfig `yaml:"musicbrainz"` + Telegram TelegramConfig `yaml:"telegram"` + Scanner ScannerConfig `yaml:"scanner"` +} + +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type NavidromeConfig struct { + URL string `yaml:"url"` + User string `yaml:"user"` + Password string `yaml:"password"` +} + +type MusicBrainzConfig struct { + UserAgent string `yaml:"user_agent"` + CacheTTL time.Duration `yaml:"cache_ttl"` +} + +type TelegramConfig struct { + Enabled bool `yaml:"enabled"` + Token string `yaml:"token"` + ChatID string `yaml:"chat_id"` + CronSchedule string `yaml:"cron_schedule"` +} + +type ScannerConfig struct { + FuzzyThreshold float64 `yaml:"fuzzy_threshold"` + IgnoreBootlegs bool `yaml:"ignore_bootlegs"` + IncludeCompilations bool `yaml:"include_compilations"` +} +``` + +### Database Schema (SQLite) +```sql +CREATE TABLE IF NOT EXISTS artist_settings ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + ignore_singles BOOLEAN DEFAULT 0, + ignore_compilations BOOLEAN DEFAULT 0, + monitored BOOLEAN DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS external_releases ( + rgid TEXT PRIMARY KEY, + artist_id TEXT NOT NULL, + title TEXT NOT NULL, + type TEXT, + release_date TEXT, + is_ignored BOOLEAN DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS notifications_sent ( + rgid TEXT NOT NULL, + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (rgid, sent_at) +); +``` + +### Project Structure After Completion +``` +naviwatcher-gitea/ +├── CLAUDE.md +├── License.md +├── README.md +├── config.yaml.example +├── docker-compose.yml +├── Dockerfile +├── .dockerignore +├── go.mod +├── go.sum +├── cmd/ +│ └── naviwatcher/ +│ └── main.go +├── internal/ +│ ├── config/ +│ │ ├── config.go +│ │ └── config_test.go +│ └── database/ +│ ├── database.go +│ ├── database_test.go +│ ├── artist_settings.go +│ ├── artist_settings_test.go +│ ├── external_releases.go +│ ├── external_releases_test.go +│ ├── notifications.go +│ └── notifications_test.go +└── docs/ + ├── Specification.md + └── plans/ + └── 2026-05-20-foundation-layer.md +``` + +## Post-Completion +*Items requiring manual intervention or external systems — no checkboxes, informational only* + +**Manual verification:** +- Copy `config.yaml.example` to `config.yaml` and fill in real values to test config loading +- Run the built Docker container against a real Navidrome instance (when client is implemented) +- Verify SQLite database file persists across container restarts via Docker volume + +**Follow-up plans needed:** +- Plan 2: Navidrome/Subsonic API client +- Plan 3: MusicBrainz provider with rate limiting +- Plan 4: Scanner engine with fuzzy matching +- Plan 5: Telegram notifier with cron scheduling +- Plan 6: Web UI with embedded templates diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c65c288 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module naviwatcher + +go 1.25.1 + +require ( + github.com/mattn/go-sqlite3 v1.14.22 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2de29e1 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0624d0e --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..323695a --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/internal/database/artist_settings.go b/internal/database/artist_settings.go new file mode 100644 index 0000000..6978bf1 --- /dev/null +++ b/internal/database/artist_settings.go @@ -0,0 +1,116 @@ +package database + +import ( + "fmt" +) + +// GetArtistSettings retrieves an artist_settings row by ID. +// Returns sql.ErrNoRows if the artist is not found. +func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) { + var s ArtistSettings + err := db.Conn().QueryRow( + "SELECT id, name, ignore_singles, ignore_compilations, monitored FROM artist_settings WHERE id = ?", + id, + ).Scan(&s.ID, &s.Name, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored) + if err != nil { + return nil, err + } + return &s, nil +} + +// SaveArtistSettings inserts or replaces an artist_settings row. +func SaveArtistSettings(db *DB, settings *ArtistSettings) error { + _, err := db.Conn().Exec( + "INSERT OR REPLACE INTO artist_settings (id, name, ignore_singles, ignore_compilations, monitored) VALUES (?, ?, ?, ?, ?)", + settings.ID, settings.Name, settings.IgnoreSingles, settings.IgnoreCompilations, settings.Monitored, + ) + if err != nil { + return fmt.Errorf("save artist settings: %w", err) + } + return nil +} + +// GetAllArtistSettings returns all rows from artist_settings. +func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) { + rows, err := db.Conn().Query( + "SELECT id, name, ignore_singles, ignore_compilations, monitored FROM artist_settings", + ) + if err != nil { + return nil, fmt.Errorf("query all artist settings: %w", err) + } + defer rows.Close() + + var results []ArtistSettings + for rows.Next() { + var s ArtistSettings + if err := rows.Scan(&s.ID, &s.Name, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored); err != nil { + return nil, fmt.Errorf("scan artist settings: %w", err) + } + results = append(results, s) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate artist settings: %w", err) + } + return results, nil +} + +// UpdateArtistSettings updates specific fields of an artist_settings row by ID. +// The updates map keys must match column names: "name", "ignore_singles", "ignore_compilations", "monitored". +func UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) error { + if len(updates) == 0 { + return fmt.Errorf("no updates provided") + } + + // Build the query using a fixed set of allowed columns to avoid dynamic SQL. + const baseQuery = "UPDATE artist_settings SET" + + var args []interface{} + setClause := "" + for col, val := range updates { + switch col { + case "name": + if setClause != "" { + setClause += ", " + } + setClause += "name = ?" + args = append(args, val) + case "ignore_singles": + if setClause != "" { + setClause += ", " + } + setClause += "ignore_singles = ?" + args = append(args, val) + case "ignore_compilations": + if setClause != "" { + setClause += ", " + } + setClause += "ignore_compilations = ?" + args = append(args, val) + case "monitored": + if setClause != "" { + setClause += ", " + } + setClause += "monitored = ?" + args = append(args, val) + default: + return fmt.Errorf("unknown column: %s", col) + } + } + + args = append(args, id) + query := fmt.Sprintf("%s %s WHERE id = ?", baseQuery, setClause) + result, err := db.Conn().Exec(query, args...) + if err != nil { + return fmt.Errorf("update artist settings: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("artist not found: %s", id) + } + + return nil +} diff --git a/internal/database/artist_settings_test.go b/internal/database/artist_settings_test.go new file mode 100644 index 0000000..cbcf92f --- /dev/null +++ b/internal/database/artist_settings_test.go @@ -0,0 +1,307 @@ +package database + +import ( + "database/sql" + "testing" +) + +// TestGetArtistSettings_Found verifies retrieving an existing artist. +func TestGetArtistSettings_Found(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + // Insert a row directly. + _, err = db.Conn().Exec( + "INSERT INTO artist_settings (id, name, ignore_singles, ignore_compilations, monitored) VALUES (?, ?, ?, ?, ?)", + "artist-1", "Test Artist", true, false, true, + ) + if err != nil { + t.Fatalf("insert: %v", err) + } + + s, err := GetArtistSettings(db, "artist-1") + if err != nil { + t.Fatalf("GetArtistSettings() error: %v", err) + } + + if s.ID != "artist-1" { + t.Errorf("expected ID 'artist-1', got %q", s.ID) + } + if s.Name != "Test Artist" { + t.Errorf("expected Name 'Test Artist', got %q", s.Name) + } + if !s.IgnoreSingles { + t.Error("expected IgnoreSingles true") + } + if s.IgnoreCompilations { + t.Error("expected IgnoreCompilations false") + } + if !s.Monitored { + t.Error("expected Monitored true") + } +} + +// TestGetArtistSettings_NotFound verifies that a missing artist returns sql.ErrNoRows. +func TestGetArtistSettings_NotFound(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + _, err = GetArtistSettings(db, "nonexistent") + if err != sql.ErrNoRows { + t.Errorf("expected sql.ErrNoRows, got %v", err) + } +} + +// TestSaveArtistSettings_Insert verifies inserting a new artist. +func TestSaveArtistSettings_Insert(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + s := &ArtistSettings{ + ID: "artist-1", + Name: "New Artist", + IgnoreSingles: false, + IgnoreCompilations: true, + Monitored: true, + } + + if err := SaveArtistSettings(db, s); err != nil { + t.Fatalf("SaveArtistSettings() error: %v", err) + } + + // Verify it was inserted. + got, err := GetArtistSettings(db, "artist-1") + if err != nil { + t.Fatalf("GetArtistSettings() error: %v", err) + } + if got.Name != "New Artist" { + t.Errorf("expected Name 'New Artist', got %q", got.Name) + } + if got.IgnoreCompilations != true { + t.Errorf("expected IgnoreCompilations true, got %v", got.IgnoreCompilations) + } +} + +// TestSaveArtistSettings_Update verifies that SaveArtistSettings replaces an existing row. +func TestSaveArtistSettings_Update(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + // Insert initial row. + s1 := &ArtistSettings{ + ID: "artist-1", + Name: "Original Name", + IgnoreSingles: false, + IgnoreCompilations: false, + Monitored: true, + } + if err := SaveArtistSettings(db, s1); err != nil { + t.Fatalf("first SaveArtistSettings() error: %v", err) + } + + // Update the row. + s2 := &ArtistSettings{ + ID: "artist-1", + Name: "Updated Name", + IgnoreSingles: true, + IgnoreCompilations: true, + Monitored: false, + } + if err := SaveArtistSettings(db, s2); err != nil { + t.Fatalf("second SaveArtistSettings() error: %v", err) + } + + got, err := GetArtistSettings(db, "artist-1") + if err != nil { + t.Fatalf("GetArtistSettings() error: %v", err) + } + if got.Name != "Updated Name" { + t.Errorf("expected Name 'Updated Name', got %q", got.Name) + } + if !got.IgnoreSingles { + t.Error("expected IgnoreSingles true") + } + if !got.IgnoreCompilations { + t.Error("expected IgnoreCompilations true") + } + if got.Monitored { + t.Error("expected Monitored false") + } +} + +// TestGetAllArtistSettings_Empty verifies GetAllArtistSettings returns empty slice when no rows. +func TestGetAllArtistSettings_Empty(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + results, err := GetAllArtistSettings(db) + if err != nil { + t.Fatalf("GetAllArtistSettings() error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } +} + +// TestGetAllArtistSettings_Populated verifies GetAllArtistSettings returns all rows. +func TestGetAllArtistSettings_Populated(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + artists := []ArtistSettings{ + {ID: "a1", Name: "Artist One", Monitored: true}, + {ID: "a2", Name: "Artist Two", IgnoreSingles: true}, + {ID: "a3", Name: "Artist Three", IgnoreCompilations: true}, + } + + for _, a := range artists { + if err := SaveArtistSettings(db, &a); err != nil { + t.Fatalf("SaveArtistSettings(%s) error: %v", a.ID, err) + } + } + + results, err := GetAllArtistSettings(db) + if err != nil { + t.Fatalf("GetAllArtistSettings() error: %v", err) + } + if len(results) != 3 { + t.Fatalf("expected 3 results, got %d", len(results)) + } + + // Verify all artists are present (order not guaranteed, use a map). + byID := make(map[string]ArtistSettings) + for _, r := range results { + byID[r.ID] = r + } + for _, expected := range artists { + got, ok := byID[expected.ID] + if !ok { + t.Errorf("expected artist %s in results", expected.ID) + continue + } + if got.Name != expected.Name { + t.Errorf("artist %s: expected Name %q, got %q", expected.ID, expected.Name, got.Name) + } + } +} + +// TestUpdateArtistSettings_Success verifies partial updates work. +func TestUpdateArtistSettings_Success(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + // Insert initial row. + s := &ArtistSettings{ + ID: "artist-1", + Name: "Original", + IgnoreSingles: false, + IgnoreCompilations: false, + Monitored: true, + } + if err := SaveArtistSettings(db, s); err != nil { + t.Fatalf("SaveArtistSettings() error: %v", err) + } + + // Update only name and monitored. + updates := map[string]interface{}{ + "name": "Updated", + "monitored": false, + } + if err := UpdateArtistSettings(db, "artist-1", updates); err != nil { + t.Fatalf("UpdateArtistSettings() error: %v", err) + } + + got, err := GetArtistSettings(db, "artist-1") + if err != nil { + t.Fatalf("GetArtistSettings() error: %v", err) + } + if got.Name != "Updated" { + t.Errorf("expected Name 'Updated', got %q", got.Name) + } + if got.Monitored { + t.Error("expected Monitored false") + } + // Unchanged fields should remain. + if got.IgnoreSingles != false { + t.Error("expected IgnoreSingles unchanged (false)") + } + if got.IgnoreCompilations != false { + t.Error("expected IgnoreCompilations unchanged (false)") + } +} + +// TestUpdateArtistSettings_NotFound verifies updating a nonexistent artist returns error. +func TestUpdateArtistSettings_NotFound(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + updates := map[string]interface{}{"name": "Ghost"} + err = UpdateArtistSettings(db, "nonexistent", updates) + if err == nil { + t.Error("expected error for nonexistent artist, got nil") + } +} + +// TestUpdateArtistSettings_InvalidColumn verifies unknown columns are rejected. +func TestUpdateArtistSettings_InvalidColumn(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + s := &ArtistSettings{ID: "artist-1", Name: "Test"} + if err := SaveArtistSettings(db, s); err != nil { + t.Fatalf("SaveArtistSettings() error: %v", err) + } + + updates := map[string]interface{}{"invalid_col": "value"} + err = UpdateArtistSettings(db, "artist-1", updates) + if err == nil { + t.Error("expected error for invalid column, got nil") + } +} + +// TestUpdateArtistSettings_EmptyUpdates verifies empty updates map returns error. +func TestUpdateArtistSettings_EmptyUpdates(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + s := &ArtistSettings{ID: "artist-1", Name: "Test"} + if err := SaveArtistSettings(db, s); err != nil { + t.Fatalf("SaveArtistSettings() error: %v", err) + } + + updates := map[string]interface{}{} + err = UpdateArtistSettings(db, "artist-1", updates) + if err == nil { + t.Error("expected error for empty updates, got nil") + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..6e50f8a --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,172 @@ +package database + +import ( + "database/sql" + "fmt" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// DB wraps sql.DB with migration support. +type DB struct { + conn *sql.DB +} + +// New opens a SQLite database at dbPath and runs schema migrations. +func New(dbPath string) (*DB, error) { + conn, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Enable WAL mode for better concurrent read performance. + if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil { + conn.Close() + return nil, fmt.Errorf("set WAL mode: %w", err) + } + + // Enable foreign key enforcement. + if _, err := conn.Exec("PRAGMA foreign_keys=ON"); err != nil { + conn.Close() + return nil, fmt.Errorf("enable foreign keys: %w", err) + } + + // Set busy timeout to handle concurrent write contention. + if _, err := conn.Exec("PRAGMA busy_timeout=5000"); err != nil { + conn.Close() + return nil, fmt.Errorf("set busy timeout: %w", err) + } + + db := &DB{conn: conn} + if err := db.migrate(); err != nil { + conn.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return db, nil +} + +// Close closes the database connection. +func (db *DB) Close() error { + return db.conn.Close() +} + +// Conn returns the underlying sql.DB for use by other packages. +func (db *DB) Conn() *sql.DB { + return db.conn +} + +// migrate runs all pending schema migrations in order. +func (db *DB) migrate() error { + // Create the migrations tracking table first, unconditionally. + if _, err := db.conn.Exec(`CREATE TABLE IF NOT EXISTS _migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`); err != nil { + return fmt.Errorf("create migrations table: %w", err) + } + + migrations := []struct { + name string + sql string + }{ + { + name: "001_create_artist_settings", + sql: `CREATE TABLE IF NOT EXISTS artist_settings ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + ignore_singles BOOLEAN DEFAULT 0, + ignore_compilations BOOLEAN DEFAULT 0, + monitored BOOLEAN DEFAULT 1 + );`, + }, + { + name: "002_create_external_releases", + sql: `CREATE TABLE IF NOT EXISTS external_releases ( + rgid TEXT PRIMARY KEY, + artist_id TEXT NOT NULL REFERENCES artist_settings(id), + title TEXT NOT NULL, + type TEXT, + release_date TEXT, + is_ignored BOOLEAN DEFAULT 0 + );`, + }, + { + name: "003_create_notifications_sent", + sql: `CREATE TABLE IF NOT EXISTS notifications_sent ( + rgid TEXT NOT NULL REFERENCES external_releases(rgid), + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (rgid, sent_at) + );`, + }, + } + + for _, m := range migrations { + applied, err := db.isMigrationApplied(m.name) + if err != nil { + return fmt.Errorf("check migration %s: %w", m.name, err) + } + if applied { + continue + } + + tx, err := db.conn.Begin() + if err != nil { + return fmt.Errorf("begin transaction for migration %s: %w", m.name, err) + } + + if _, err := tx.Exec(m.sql); err != nil { + tx.Rollback() + return fmt.Errorf("apply migration %s: %w", m.name, err) + } + + if _, err := tx.Exec("INSERT INTO _migrations (name) VALUES (?)", m.name); err != nil { + tx.Rollback() + return fmt.Errorf("record migration %s: %w", m.name, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", m.name, err) + } + } + + return nil +} + +// isMigrationApplied checks whether a migration with the given name has already been applied. +func (db *DB) isMigrationApplied(name string) (bool, error) { + var count int + err := db.conn.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = ?", name).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + + +// ArtistSettings represents a row in the artist_settings table. +type ArtistSettings struct { + ID string `json:"id"` + Name string `json:"name"` + IgnoreSingles bool `json:"ignore_singles"` + IgnoreCompilations bool `json:"ignore_compilations"` + Monitored bool `json:"monitored"` +} + +// ExternalRelease represents a row in the external_releases table. +type ExternalRelease struct { + RGID string `json:"rgid"` + ArtistID string `json:"artist_id"` + Title string `json:"title"` + Type string `json:"type"` + ReleaseDate string `json:"release_date"` + IsIgnored bool `json:"is_ignored"` +} + +// NotificationSent represents a row in the notifications_sent table. +type NotificationSent struct { + RGID string `json:"rgid"` + SentAt time.Time `json:"sent_at"` +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..644127c --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,211 @@ +package database + +import ( + "testing" +) + +// TestNew_InitializationAndSchema verifies that New() creates all expected tables. +func TestNew_InitializationAndSchema(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + expectedTables := []string{ + "_migrations", + "artist_settings", + "external_releases", + "notifications_sent", + } + + for _, table := range expectedTables { + var name string + err := db.Conn().QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table, + ).Scan(&name) + if err != nil { + t.Errorf("expected table %q to exist, got error: %v", table, err) + } + } +} + +// TestNew_MigrationIdempency verifies that calling New() twice (via migrate) does not fail. +func TestNew_MigrationIdempotency(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("first New() error: %v", err) + } + + // Running migrate again on the same connection should be a no-op. + err = db.migrate() + if err != nil { + t.Fatalf("second migrate() error: %v", err) + } + + // Verify tables still exist. + var count int + err = db.Conn().QueryRow( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN (?,?,?,?)", + "_migrations", "artist_settings", "external_releases", "notifications_sent", + ).Scan(&count) + if err != nil { + t.Fatalf("query error: %v", err) + } + if count != 4 { + t.Errorf("expected 4 tables, got %d", count) + } + + db.Close() +} + +// TestClose verifies that Close() properly closes the connection. +func TestClose(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + + if err := db.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + // After close, queries should fail. + var dummy int + err = db.Conn().QueryRow("SELECT 1").Scan(&dummy) + if err == nil { + t.Error("expected error querying after Close(), got nil") + } +} + +// TestArtistSettingsSchema verifies the artist_settings table has the correct columns. +func TestArtistSettingsSchema(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + // Insert a row to verify column names and types. + _, err = db.Conn().Exec( + "INSERT INTO artist_settings (id, name, ignore_singles, ignore_compilations, monitored) VALUES (?, ?, ?, ?, ?)", + "artist-1", "Test Artist", true, false, true, + ) + if err != nil { + t.Fatalf("insert into artist_settings: %v", err) + } + + var id, name string + var ignoreSingles, ignoreCompilations, monitored bool + err = db.Conn().QueryRow( + "SELECT id, name, ignore_singles, ignore_compilations, monitored FROM artist_settings WHERE id = ?", + "artist-1", + ).Scan(&id, &name, &ignoreSingles, &ignoreCompilations, &monitored) + if err != nil { + t.Fatalf("select from artist_settings: %v", err) + } + + if id != "artist-1" || name != "Test Artist" || !ignoreSingles || ignoreCompilations || !monitored { + t.Errorf("unexpected row values: id=%q name=%q ignoreSingles=%v ignoreCompilations=%v monitored=%v", + id, name, ignoreSingles, ignoreCompilations, monitored) + } +} + +// TestExternalReleasesSchema verifies the external_releases table has the correct columns. +func TestExternalReleasesSchema(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + // Insert parent artist first (FK requirement). + _, err = db.Conn().Exec("INSERT INTO artist_settings (id, name) VALUES (?, ?)", "artist-1", "Test Artist") + if err != nil { + t.Fatalf("insert artist: %v", err) + } + _, err = db.Conn().Exec( + "INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored) VALUES (?, ?, ?, ?, ?, ?)", + "rgid-1", "artist-1", "Test Album", "album", "2024-01-01", false, + ) + if err != nil { + t.Fatalf("insert into external_releases: %v", err) + } + + var rgid, artistID, title, releaseType, releaseDate string + var isIgnored bool + err = db.Conn().QueryRow( + "SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE rgid = ?", + "rgid-1", + ).Scan(&rgid, &artistID, &title, &releaseType, &releaseDate, &isIgnored) + if err != nil { + t.Fatalf("select from external_releases: %v", err) + } + + if rgid != "rgid-1" || artistID != "artist-1" || title != "Test Album" || releaseType != "album" || releaseDate != "2024-01-01" || isIgnored { + t.Errorf("unexpected row values") + } +} + +// TestNotificationsSentSchema verifies the notifications_sent table has the correct columns. +func TestNotificationsSentSchema(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + // Insert parent artist and release first (FK requirements). + _, err = db.Conn().Exec("INSERT INTO artist_settings (id, name) VALUES (?, ?)", "artist-1", "Test Artist") + if err != nil { + t.Fatalf("insert artist: %v", err) + } + _, err = db.Conn().Exec("INSERT INTO external_releases (rgid, artist_id, title) VALUES (?, ?, ?)", "rgid-1", "artist-1", "Test Album") + if err != nil { + t.Fatalf("insert release: %v", err) + } + _, err = db.Conn().Exec( + "INSERT INTO notifications_sent (rgid) VALUES (?)", + "rgid-1", + ) + if err != nil { + t.Fatalf("insert into notifications_sent: %v", err) + } + + var rgid string + var sentAt string + err = db.Conn().QueryRow( + "SELECT rgid, sent_at FROM notifications_sent WHERE rgid = ?", + "rgid-1", + ).Scan(&rgid, &sentAt) + if err != nil { + t.Fatalf("select from notifications_sent: %v", err) + } + + if rgid != "rgid-1" { + t.Errorf("expected rgid 'rgid-1', got %q", rgid) + } + if sentAt == "" { + t.Error("expected sent_at to be non-empty") + } +} + +// TestMigrationTracking verifies that migrations are recorded in _migrations table. +func TestMigrationTracking(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + var count int + err = db.Conn().QueryRow("SELECT COUNT(*) FROM _migrations").Scan(&count) + if err != nil { + t.Fatalf("query migrations count: %v", err) + } + + // We have 3 recorded migrations: artist_settings, external_releases, notifications_sent. + if count != 3 { + t.Errorf("expected 3 applied migrations, got %d", count) + } +} diff --git a/internal/database/external_releases.go b/internal/database/external_releases.go new file mode 100644 index 0000000..4a045ec --- /dev/null +++ b/internal/database/external_releases.go @@ -0,0 +1,101 @@ +package database + +import ( + "fmt" +) + +// GetExternalRelease retrieves an external_release row by RGID. +// Returns sql.ErrNoRows if the release is not found. +func GetExternalRelease(db *DB, rgid string) (*ExternalRelease, error) { + var r ExternalRelease + err := db.Conn().QueryRow( + "SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE rgid = ?", + rgid, + ).Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored) + if err != nil { + return nil, err + } + return &r, nil +} + +// SaveExternalRelease inserts or replaces an external_release row. +func SaveExternalRelease(db *DB, release *ExternalRelease) error { + _, err := db.Conn().Exec( + "INSERT OR REPLACE INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored) VALUES (?, ?, ?, ?, ?, ?)", + release.RGID, release.ArtistID, release.Title, release.Type, release.ReleaseDate, release.IsIgnored, + ) + if err != nil { + return fmt.Errorf("save external release: %w", err) + } + return nil +} + +// GetExternalReleasesByArtist returns all external_release rows for a given artist_id. +func GetExternalReleasesByArtist(db *DB, artistID string) ([]ExternalRelease, error) { + rows, err := db.Conn().Query( + "SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE artist_id = ?", + artistID, + ) + if err != nil { + return nil, fmt.Errorf("query external releases by artist: %w", err) + } + defer rows.Close() + + var results []ExternalRelease + for rows.Next() { + var r ExternalRelease + if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored); err != nil { + return nil, fmt.Errorf("scan external release: %w", err) + } + results = append(results, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate external releases: %w", err) + } + return results, nil +} + +// GetIgnoredReleases returns all external_release rows where is_ignored = 1. +func GetIgnoredReleases(db *DB) ([]ExternalRelease, error) { + rows, err := db.Conn().Query( + "SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE is_ignored = 1", + ) + if err != nil { + return nil, fmt.Errorf("query ignored releases: %w", err) + } + defer rows.Close() + + var results []ExternalRelease + for rows.Next() { + var r ExternalRelease + if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored); err != nil { + return nil, fmt.Errorf("scan ignored release: %w", err) + } + results = append(results, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate ignored releases: %w", err) + } + return results, nil +} + +// SetReleaseIgnored updates the is_ignored flag for a given RGID. +func SetReleaseIgnored(db *DB, rgid string, ignored bool) error { + result, err := db.Conn().Exec( + "UPDATE external_releases SET is_ignored = ? WHERE rgid = ?", + ignored, rgid, + ) + if err != nil { + return fmt.Errorf("set release ignored: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("release not found: %s", rgid) + } + + return nil +} diff --git a/internal/database/external_releases_test.go b/internal/database/external_releases_test.go new file mode 100644 index 0000000..57d95a3 --- /dev/null +++ b/internal/database/external_releases_test.go @@ -0,0 +1,378 @@ +package database + +import ( + "database/sql" + "testing" +) + +// insertTestArtist inserts a minimal artist_settings row for use in tests that need FK satisfaction. +func insertTestArtist(db *DB, id string) error { + _, err := db.Conn().Exec( + "INSERT OR IGNORE INTO artist_settings (id, name) VALUES (?, ?)", + id, "Test Artist "+id, + ) + return err +} + +// TestGetExternalRelease_Found verifies retrieving an existing release. +func TestGetExternalRelease_Found(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + _, err = db.Conn().Exec( + "INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored) VALUES (?, ?, ?, ?, ?, ?)", + "rgid-1", "artist-1", "Test Album", "album", "2024-01-01", false, + ) + if err != nil { + t.Fatalf("insert: %v", err) + } + + r, err := GetExternalRelease(db, "rgid-1") + if err != nil { + t.Fatalf("GetExternalRelease() error: %v", err) + } + + if r.RGID != "rgid-1" { + t.Errorf("expected RGID 'rgid-1', got %q", r.RGID) + } + if r.ArtistID != "artist-1" { + t.Errorf("expected ArtistID 'artist-1', got %q", r.ArtistID) + } + if r.Title != "Test Album" { + t.Errorf("expected Title 'Test Album', got %q", r.Title) + } + if r.Type != "album" { + t.Errorf("expected Type 'album', got %q", r.Type) + } + if r.ReleaseDate != "2024-01-01" { + t.Errorf("expected ReleaseDate '2024-01-01', got %q", r.ReleaseDate) + } + if r.IsIgnored { + t.Error("expected IsIgnored false") + } +} + +// TestGetExternalRelease_NotFound verifies that a missing release returns sql.ErrNoRows. +func TestGetExternalRelease_NotFound(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + _, err = GetExternalRelease(db, "nonexistent") + if err != sql.ErrNoRows { + t.Errorf("expected sql.ErrNoRows, got %v", err) + } +} + +// TestSaveExternalRelease_Insert verifies inserting a new release. +func TestSaveExternalRelease_Insert(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + r := &ExternalRelease{ + RGID: "rgid-1", + ArtistID: "artist-1", + Title: "New Album", + Type: "album", + ReleaseDate: "2024-06-15", + IsIgnored: false, + } + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + if err := SaveExternalRelease(db, r); err != nil { + t.Fatalf("SaveExternalRelease() error: %v", err) + } + + got, err := GetExternalRelease(db, "rgid-1") + if err != nil { + t.Fatalf("GetExternalRelease() error: %v", err) + } + if got.Title != "New Album" { + t.Errorf("expected Title 'New Album', got %q", got.Title) + } + if got.ReleaseDate != "2024-06-15" { + t.Errorf("expected ReleaseDate '2024-06-15', got %q", got.ReleaseDate) + } +} + +// TestSaveExternalRelease_Update verifies that SaveExternalRelease replaces an existing row. +func TestSaveExternalRelease_Update(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + + // Insert initial row. + r1 := &ExternalRelease{ + RGID: "rgid-1", + ArtistID: "artist-1", + Title: "Original Title", + Type: "album", + ReleaseDate: "2024-01-01", + IsIgnored: false, + } + if err := SaveExternalRelease(db, r1); err != nil { + t.Fatalf("first SaveExternalRelease() error: %v", err) + } + + // Update the row. + r2 := &ExternalRelease{ + RGID: "rgid-1", + ArtistID: "artist-1", + Title: "Updated Title", + Type: "single", + ReleaseDate: "2024-12-25", + IsIgnored: true, + } + if err := SaveExternalRelease(db, r2); err != nil { + t.Fatalf("second SaveExternalRelease() error: %v", err) + } + + got, err := GetExternalRelease(db, "rgid-1") + if err != nil { + t.Fatalf("GetExternalRelease() error: %v", err) + } + if got.Title != "Updated Title" { + t.Errorf("expected Title 'Updated Title', got %q", got.Title) + } + if got.Type != "single" { + t.Errorf("expected Type 'single', got %q", got.Type) + } + if got.ReleaseDate != "2024-12-25" { + t.Errorf("expected ReleaseDate '2024-12-25', got %q", got.ReleaseDate) + } + if !got.IsIgnored { + t.Error("expected IsIgnored true") + } +} + +// TestGetExternalReleasesByArtist_Empty verifies empty result for unknown artist. +func TestGetExternalReleasesByArtist_Empty(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + results, err := GetExternalReleasesByArtist(db, "unknown-artist") + if err != nil { + t.Fatalf("GetExternalReleasesByArtist() error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } +} + +// TestGetExternalReleasesByArtist_Populated verifies filtering by artist_id. +func TestGetExternalReleasesByArtist_Populated(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + releases := []ExternalRelease{ + {RGID: "rg1", ArtistID: "artist-a", Title: "Album A1", Type: "album", ReleaseDate: "2024-01-01"}, + {RGID: "rg2", ArtistID: "artist-a", Title: "Album A2", Type: "album", ReleaseDate: "2024-06-01"}, + {RGID: "rg3", ArtistID: "artist-b", Title: "Album B1", Type: "single", ReleaseDate: "2024-03-01"}, + } + + if err := insertTestArtist(db, "artist-a"); err != nil { + t.Fatalf("insertTestArtist(artist-a): %v", err) + } + if err := insertTestArtist(db, "artist-b"); err != nil { + t.Fatalf("insertTestArtist(artist-b): %v", err) + } + for _, r := range releases { + if err := SaveExternalRelease(db, &r); err != nil { + t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err) + } + } + + // Query for artist-a. + results, err := GetExternalReleasesByArtist(db, "artist-a") + if err != nil { + t.Fatalf("GetExternalReleasesByArtist() error: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results for artist-a, got %d", len(results)) + } + + // Verify all belong to artist-a. + for _, r := range results { + if r.ArtistID != "artist-a" { + t.Errorf("expected ArtistID 'artist-a', got %q", r.ArtistID) + } + } + + // Query for artist-b. + results, err = GetExternalReleasesByArtist(db, "artist-b") + if err != nil { + t.Fatalf("GetExternalReleasesByArtist() error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result for artist-b, got %d", len(results)) + } + if results[0].Title != "Album B1" { + t.Errorf("expected Title 'Album B1', got %q", results[0].Title) + } +} + +// TestGetIgnoredReleases_Empty verifies empty result when no ignored releases. +func TestGetIgnoredReleases_Empty(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + results, err := GetIgnoredReleases(db) + if err != nil { + t.Fatalf("GetIgnoredReleases() error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } +} + +// TestGetIgnoredReleases_Populated verifies only ignored releases are returned. +func TestGetIgnoredReleases_Populated(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + releases := []ExternalRelease{ + {RGID: "rg1", ArtistID: "a1", Title: "Ignored Album", IsIgnored: true}, + {RGID: "rg2", ArtistID: "a1", Title: "Normal Album", IsIgnored: false}, + {RGID: "rg3", ArtistID: "a2", Title: "Another Ignored", IsIgnored: true}, + } + + if err := insertTestArtist(db, "a1"); err != nil { + t.Fatalf("insertTestArtist(a1): %v", err) + } + if err := insertTestArtist(db, "a2"); err != nil { + t.Fatalf("insertTestArtist(a2): %v", err) + } + for _, r := range releases { + if err := SaveExternalRelease(db, &r); err != nil { + t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err) + } + } + + results, err := GetIgnoredReleases(db) + if err != nil { + t.Fatalf("GetIgnoredReleases() error: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 ignored releases, got %d", len(results)) + } + + // Verify all returned are ignored. + for _, r := range results { + if !r.IsIgnored { + t.Errorf("expected IsIgnored=true for %s, got false", r.RGID) + } + } +} + +// TestSetReleaseIgnored_SetTrue verifies setting is_ignored to true. +func TestSetReleaseIgnored_SetTrue(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + r := &ExternalRelease{ + RGID: "rgid-1", + ArtistID: "artist-1", + Title: "Test Album", + IsIgnored: false, + } + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + if err := SaveExternalRelease(db, r); err != nil { + t.Fatalf("SaveExternalRelease() error: %v", err) + } + + if err := SetReleaseIgnored(db, "rgid-1", true); err != nil { + t.Fatalf("SetReleaseIgnored() error: %v", err) + } + + got, err := GetExternalRelease(db, "rgid-1") + if err != nil { + t.Fatalf("GetExternalRelease() error: %v", err) + } + if !got.IsIgnored { + t.Error("expected IsIgnored true after SetReleaseIgnored(true)") + } +} + +// TestSetReleaseIgnored_SetFalse verifies setting is_ignored back to false. +func TestSetReleaseIgnored_SetFalse(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + r := &ExternalRelease{ + RGID: "rgid-1", + ArtistID: "artist-1", + Title: "Test Album", + IsIgnored: true, + } + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + if err := SaveExternalRelease(db, r); err != nil { + t.Fatalf("SaveExternalRelease() error: %v", err) + } + + if err := SetReleaseIgnored(db, "rgid-1", false); err != nil { + t.Fatalf("SetReleaseIgnored() error: %v", err) + } + + got, err := GetExternalRelease(db, "rgid-1") + if err != nil { + t.Fatalf("GetExternalRelease() error: %v", err) + } + if got.IsIgnored { + t.Error("expected IsIgnored false after SetReleaseIgnored(false)") + } +} + +// TestSetReleaseIgnored_NotFound verifies error for nonexistent RGID. +func TestSetReleaseIgnored_NotFound(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + err = SetReleaseIgnored(db, "nonexistent", true) + if err == nil { + t.Error("expected error for nonexistent RGID, got nil") + } +} diff --git a/internal/database/notifications.go b/internal/database/notifications.go new file mode 100644 index 0000000..e457134 --- /dev/null +++ b/internal/database/notifications.go @@ -0,0 +1,69 @@ +package database + +import ( + "fmt" +) + +// MarkNotificationSent records that a notification has been sent for the given RGID. +func MarkNotificationSent(db *DB, rgid string) error { + _, err := db.Conn().Exec( + "INSERT INTO notifications_sent (rgid) VALUES (?)", + rgid, + ) + if err != nil { + return fmt.Errorf("mark notification sent: %w", err) + } + return nil +} + +// IsNotificationSent checks whether a notification has already been sent for the given RGID. +func IsNotificationSent(db *DB, rgid string) (bool, error) { + var count int + err := db.Conn().QueryRow( + "SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", rgid, + ).Scan(&count) + if err != nil { + return false, fmt.Errorf("check notification sent: %w", err) + } + return count > 0, nil +} + +// GetUnnotifiedReleases returns all external_release rows that have no entry in notifications_sent. +func GetUnnotifiedReleases(db *DB) ([]ExternalRelease, error) { + rows, err := db.Conn().Query(` + SELECT e.rgid, e.artist_id, e.title, e.type, e.release_date, e.is_ignored + FROM external_releases e + LEFT JOIN notifications_sent n ON e.rgid = n.rgid + WHERE n.rgid IS NULL AND e.is_ignored = 0 + `) + if err != nil { + return nil, fmt.Errorf("query unnotified releases: %w", err) + } + defer rows.Close() + + var results []ExternalRelease + for rows.Next() { + var r ExternalRelease + if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored); err != nil { + return nil, fmt.Errorf("scan unnotified release: %w", err) + } + results = append(results, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate unnotified releases: %w", err) + } + return results, nil +} + +// GetNotificationSentAt returns the sent_at time for a given RGID. +// Returns sql.ErrNoRows if no notification has been sent. +func GetNotificationSentAt(db *DB, rgid string) (string, error) { + var sentAt string + err := db.Conn().QueryRow( + "SELECT sent_at FROM notifications_sent WHERE rgid = ? ORDER BY sent_at DESC LIMIT 1", rgid, + ).Scan(&sentAt) + if err != nil { + return "", err + } + return sentAt, nil +} diff --git a/internal/database/notifications_test.go b/internal/database/notifications_test.go new file mode 100644 index 0000000..a5f87e0 --- /dev/null +++ b/internal/database/notifications_test.go @@ -0,0 +1,407 @@ +package database + +import ( + "database/sql" + "testing" +) + +// insertTestRelease inserts a minimal external_releases row for use in tests that need FK satisfaction. +func insertTestRelease(db *DB, rgid, artistID string) error { + _, err := db.Conn().Exec( + "INSERT OR IGNORE INTO external_releases (rgid, artist_id, title) VALUES (?, ?, ?)", + rgid, artistID, "Test Release "+rgid, + ) + return err +} + +// TestMarkNotificationSent_New verifies inserting a new notification record. +func TestMarkNotificationSent_New(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + if err := insertTestRelease(db, "rgid-1", "artist-1"); err != nil { + t.Fatalf("insertTestRelease: %v", err) + } + if err := MarkNotificationSent(db, "rgid-1"); err != nil { + t.Fatalf("MarkNotificationSent() error: %v", err) + } + + sentAt, err := GetNotificationSentAt(db, "rgid-1") + if err != nil { + t.Fatalf("GetNotificationSentAt() error: %v", err) + } + if sentAt == "" { + t.Error("expected sent_at to be non-empty") + } +} + +// TestMarkNotificationSent_DuplicateSecond verifies that inserting the same RGID twice +// within the same second fails due to the composite primary key (rgid, sent_at). +// In practice, notifications are sent at most once per day, so this is acceptable. +func TestMarkNotificationSent_DuplicateSecond(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + if err := insertTestRelease(db, "rgid-1", "artist-1"); err != nil { + t.Fatalf("insertTestRelease: %v", err) + } + if err := MarkNotificationSent(db, "rgid-1"); err != nil { + t.Fatalf("first MarkNotificationSent() error: %v", err) + } + // Second insert in the same second should fail with a UNIQUE constraint error. + err = MarkNotificationSent(db, "rgid-1") + if err == nil { + t.Fatal("expected UNIQUE constraint error on duplicate insert, got nil") + } + + // Should still have exactly one row. + var count int + err = db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rgid-1").Scan(&count) + if err != nil { + t.Fatalf("count query error: %v", err) + } + if count != 1 { + t.Errorf("expected 1 notification row, got %d", count) + } +} + +// TestMarkNotificationSent_DifferentTime verifies that inserting the same RGID at a +// different explicit sent_at time succeeds (composite PK allows multiple rows per RGID). +func TestMarkNotificationSent_DifferentTime(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + if err := insertTestRelease(db, "rgid-1", "artist-1"); err != nil { + t.Fatalf("insertTestRelease: %v", err) + } + + // Insert with explicit different timestamps. + _, err = db.Conn().Exec("INSERT INTO notifications_sent (rgid, sent_at) VALUES (?, ?)", "rgid-1", "2024-01-01T00:00:00Z") + if err != nil { + t.Fatalf("first insert error: %v", err) + } + _, err = db.Conn().Exec("INSERT INTO notifications_sent (rgid, sent_at) VALUES (?, ?)", "rgid-1", "2024-06-01T00:00:00Z") + if err != nil { + t.Fatalf("second insert error: %v", err) + } + + // IsNotificationSent should return true. + sent, err := IsNotificationSent(db, "rgid-1") + if err != nil { + t.Fatalf("IsNotificationSent() error: %v", err) + } + if !sent { + t.Error("expected IsNotificationSent to return true") + } + + // Should have two rows. + var count int + err = db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rgid-1").Scan(&count) + if err != nil { + t.Fatalf("count query error: %v", err) + } + if count != 2 { + t.Errorf("expected 2 notification rows, got %d", count) + } +} + +// TestIsNotificationSent_True verifies true for a sent notification. +func TestIsNotificationSent_True(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + if err := insertTestRelease(db, "rgid-1", "artist-1"); err != nil { + t.Fatalf("insertTestRelease: %v", err) + } + if err := MarkNotificationSent(db, "rgid-1"); err != nil { + t.Fatalf("MarkNotificationSent() error: %v", err) + } + + sent, err := IsNotificationSent(db, "rgid-1") + if err != nil { + t.Fatalf("IsNotificationSent() error: %v", err) + } + if !sent { + t.Error("expected IsNotificationSent to return true") + } +} + +// TestIsNotificationSent_False verifies false for an unsent RGID. +func TestIsNotificationSent_False(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + sent, err := IsNotificationSent(db, "nonexistent") + if err != nil { + t.Fatalf("IsNotificationSent() error: %v", err) + } + if sent { + t.Error("expected IsNotificationSent to return false for nonexistent RGID") + } +} + +// TestGetNotificationSentAt_NotFound verifies sql.ErrNoRows for unsent RGID. +func TestGetNotificationSentAt_NotFound(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + _, err = GetNotificationSentAt(db, "nonexistent") + if err != sql.ErrNoRows { + t.Errorf("expected sql.ErrNoRows, got %v", err) + } +} + +// TestGetUnnotifiedReleases_AllUnnotified verifies all releases returned when no notifications sent. +func TestGetUnnotifiedReleases_AllUnnotified(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + releases := []ExternalRelease{ + {RGID: "rg1", ArtistID: "artist-1", Title: "Album 1", Type: "album", ReleaseDate: "2024-01-01"}, + {RGID: "rg2", ArtistID: "artist-1", Title: "Album 2", Type: "album", ReleaseDate: "2024-06-01"}, + {RGID: "rg3", ArtistID: "artist-2", Title: "Single 1", Type: "single", ReleaseDate: "2024-03-01"}, + } + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist(artist-1): %v", err) + } + if err := insertTestArtist(db, "artist-2"); err != nil { + t.Fatalf("insertTestArtist(artist-2): %v", err) + } + for _, r := range releases { + if err := SaveExternalRelease(db, &r); err != nil { + t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err) + } + } + + results, err := GetUnnotifiedReleases(db) + if err != nil { + t.Fatalf("GetUnnotifiedReleases() error: %v", err) + } + if len(results) != 3 { + t.Fatalf("expected 3 unnotified releases, got %d", len(results)) + } +} + +// TestGetUnnotifiedReleases_SomeNotified verifies only unnotified releases are returned. +func TestGetUnnotifiedReleases_SomeNotified(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + releases := []ExternalRelease{ + {RGID: "rg1", ArtistID: "artist-1", Title: "Album 1", Type: "album", ReleaseDate: "2024-01-01"}, + {RGID: "rg2", ArtistID: "artist-1", Title: "Album 2", Type: "album", ReleaseDate: "2024-06-01"}, + {RGID: "rg3", ArtistID: "artist-2", Title: "Single 1", Type: "single", ReleaseDate: "2024-03-01"}, + } + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist(artist-1): %v", err) + } + if err := insertTestArtist(db, "artist-2"); err != nil { + t.Fatalf("insertTestArtist(artist-2): %v", err) + } + for _, r := range releases { + if err := SaveExternalRelease(db, &r); err != nil { + t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err) + } + } + + // Mark rg1 and rg3 as notified. + if err := MarkNotificationSent(db, "rg1"); err != nil { + t.Fatalf("MarkNotificationSent(rg1) error: %v", err) + } + if err := MarkNotificationSent(db, "rg3"); err != nil { + t.Fatalf("MarkNotificationSent(rg3) error: %v", err) + } + + results, err := GetUnnotifiedReleases(db) + if err != nil { + t.Fatalf("GetUnnotifiedReleases() error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 unnotified release, got %d", len(results)) + } + if results[0].RGID != "rg2" { + t.Errorf("expected unnotified release rg2, got %s", results[0].RGID) + } + if results[0].Title != "Album 2" { + t.Errorf("expected Title 'Album 2', got %q", results[0].Title) + } +} + +// TestGetUnnotifiedReleases_AllNotified verifies empty result when all releases are notified. +func TestGetUnnotifiedReleases_AllNotified(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + releases := []ExternalRelease{ + {RGID: "rg1", ArtistID: "artist-1", Title: "Album 1"}, + {RGID: "rg2", ArtistID: "artist-1", Title: "Album 2"}, + } + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist(artist-1): %v", err) + } + for _, r := range releases { + if err := SaveExternalRelease(db, &r); err != nil { + t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err) + } + } + + // Mark all as notified. + for _, r := range releases { + if err := MarkNotificationSent(db, r.RGID); err != nil { + t.Fatalf("MarkNotificationSent(%s) error: %v", r.RGID, err) + } + } + + results, err := GetUnnotifiedReleases(db) + if err != nil { + t.Fatalf("GetUnnotifiedReleases() error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 unnotified releases, got %d", len(results)) + } +} + +// TestGetUnnotifiedReleases_NoReleases verifies empty result when no releases exist. +func TestGetUnnotifiedReleases_NoReleases(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + results, err := GetUnnotifiedReleases(db) + if err != nil { + t.Fatalf("GetUnnotifiedReleases() error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 unnotified releases, got %d", len(results)) + } +} + +// TestMarkNotificationSent_MultipleReleases verifies marking multiple different RGIDs. +func TestMarkNotificationSent_MultipleReleases(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + for _, rgid := range []string{"rg1", "rg2", "rg3"} { + if err := insertTestRelease(db, rgid, "artist-1"); err != nil { + t.Fatalf("insertTestRelease(%s): %v", rgid, err) + } + } + + rgids := []string{"rg1", "rg2", "rg3"} + for _, rgid := range rgids { + if err := MarkNotificationSent(db, rgid); err != nil { + t.Fatalf("MarkNotificationSent(%s) error: %v", rgid, err) + } + } + + for _, rgid := range rgids { + sent, err := IsNotificationSent(db, rgid) + if err != nil { + t.Fatalf("IsNotificationSent(%s) error: %v", rgid, err) + } + if !sent { + t.Errorf("expected IsNotificationSent(%s) to return true", rgid) + } + } + + // Verify count. + var count int + err = db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent").Scan(&count) + if err != nil { + t.Fatalf("count query error: %v", err) + } + if count != 3 { + t.Errorf("expected 3 notification rows, got %d", count) + } +} + +// TestGetUnnotifiedReleases_IgnoredExcluded verifies that releases marked as ignored +// are not returned by GetUnnotifiedReleases, per the notification lifecycle spec (section 4.4). +func TestGetUnnotifiedReleases_IgnoredExcluded(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := insertTestArtist(db, "artist-1"); err != nil { + t.Fatalf("insertTestArtist: %v", err) + } + + releases := []ExternalRelease{ + {RGID: "rg1", ArtistID: "artist-1", Title: "Normal Album", Type: "album", ReleaseDate: "2024-01-01", IsIgnored: false}, + {RGID: "rg2", ArtistID: "artist-1", Title: "Ignored Album", Type: "album", ReleaseDate: "2024-06-01", IsIgnored: true}, + {RGID: "rg3", ArtistID: "artist-1", Title: "Another Normal", Type: "single", ReleaseDate: "2024-03-01", IsIgnored: false}, + } + + for _, r := range releases { + if err := SaveExternalRelease(db, &r); err != nil { + t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err) + } + } + + results, err := GetUnnotifiedReleases(db) + if err != nil { + t.Fatalf("GetUnnotifiedReleases() error: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 unnotified releases (ignored excluded), got %d", len(results)) + } + + // Verify the ignored release is not in the results. + for _, r := range results { + if r.RGID == "rg2" { + t.Error("ignored release rg2 should not appear in unnotified releases") + } + } +}