13 Commits

Author SHA1 Message Date
5c2aadaab6 fix: address code review findings
- Add is_ignored=0 filter to GetUnnotifiedReleases query per spec section 4.4
  (notification lifecycle must exclude ignored releases)
- Add FK constraint on notifications_sent.rgid referencing external_releases(rgid)
  per spec schema definition
- Wrap migration application + recording in transactions for atomicity
- Add config.yaml to .gitignore to prevent accidental secret commits
- Pin Dockerfile base image to alpine:3.21 and add non-root appuser
- Add test TestGetUnnotifiedReleases_IgnoredExcluded

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:46:51 +03:00
c23b698f67 fix: address code review findings
- Fix notifications_sent schema: change to composite PK (rgid, sent_at) per plan spec, remove FK constraint
- Change MarkNotificationSent from INSERT OR REPLACE to INSERT (composite PK semantics)
- Update test: replace idempotent test with duplicate-second and different-time tests
- Refactor UpdateArtistSettings to use switch-based column validation instead of fmt.Sprintf with map lookup
- Remove generated coverage.out from repo, add to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:23:22 +03:00
47d4ec4e32 fix: address code review findings
- Fix notifications_sent PK: changed from (rgid, sent_at) to rgid-only PK
  to prevent duplicate RGID rows across seconds. Use INSERT OR REPLACE
  instead of INSERT OR IGNORE for true idempotency.
- Add foreign key constraints to DDL (artist_id references artist_settings,
  rgid references external_releases) per specification.
- Enable PRAGMA foreign_keys=ON and PRAGMA busy_timeout=5000 for concurrent
  access safety.
- Fix GetNotificationSentAt query: add ORDER BY sent_at DESC LIMIT 1 for
  deterministic results.
- Fix config test: change YAML key from 'chat' to 'chat_id' to match struct
  tag, add ChatID assertion.
- Fix migration tracking test: correct error message from "expected 4" to
  "expected 3".
- Remove dead code in TestLoadConfig_InvalidPort: eliminate unused YAML
  template and remove port 0 case (valid, not invalid).
- Remove unused path parameter from buildConfigWithPort helper.
- Remove pointless 100ms sleep in run() and unused time import.
- Remove tautological TestDefaultConfigPath test.
- Update README.md: Go version 1.21+ to 1.25+, placeholder passwords to
  CHANGE_ME.
- Update config.yaml.example: placeholder passwords to CHANGE_ME.
- Update all database tests to insert parent rows first for FK satisfaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:07:28 +03:00
fb74b5fbba feat: complete foundation layer documentation update (task 9)
Mark Task 9 checkboxes complete: README already has accurate build/run/test
instructions, config.yaml.example matches spec, no deviations from
specification found in the foundation layer implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:54:40 +03:00
2cb377614f feat: verify acceptance criteria for foundation layer
Mark Task 8 complete after verifying: go build succeeds, config loads
from YAML, all 3 database tables created via migrations, all CRUD
operations work, Docker builds, all 53 tests pass, go vet clean,
gofmt clean, coverage exceeds 70% for foundation packages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:51:02 +03:00
3206b10bd9 feat: add Docker setup with multi-stage build and docker-compose
- Add Dockerfile with golang:1.25-alpine build stage + alpine runtime stage
- Add docker-compose.yml with naviwatcher service and data volume
- Add .dockerignore for build context cleanup
- Add .gitignore for build artifacts and data directory
- Update plan: mark Task 7 checkboxes as complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:41:54 +03:00
68660e4d83 feat: add CRUD operations for notifications_sent table
Implement MarkNotificationSent, IsNotificationSent, and
GetUnnotifiedReleases with LEFT JOIN query. Includes 10
table-driven tests covering success, error, idempotent,
and edge cases. Also go fmt cleanup on existing files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:27:08 +03:00
3b87bfce96 feat: add CRUD operations for external_releases table
Implement GetExternalRelease, SaveExternalRelease,
GetExternalReleasesByArtist, GetIgnoredReleases, and
SetReleaseIgnored with 12 table-driven tests covering
success and error cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:15:37 +03:00
a052ccf11e feat: add CRUD operations for artist_settings table
Implements GetArtistSettings, SaveArtistSettings, GetAllArtistSettings,
and UpdateArtistSettings with full test coverage (10 new tests).
All 17 database tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:04:51 +03:00
da6668204f feat: add database layer with schema migrations
Add internal/database package with SQLite3 connection management,
versioned migration system, and three schema tables (artist_settings,
external_releases, notifications_sent). Includes 7 tests covering
initialization, migration idempotency, Close() behavior, and schema
validation using in-memory SQLite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 08:58:23 +03:00
6ff4ec3045 feat: add configuration management with YAML parsing and validation
Add internal/config package with Config struct (Server, Navidrome,
MusicBrainz, Telegram, Scanner sections), LoadConfig function for
YAML parsing, config validation (required fields, port range,
threshold range), and defaults. Include config.yaml.example matching
spec. Update main.go to use real config package instead of
placeholders. Add comprehensive tests covering valid configs,
defaults, missing files, malformed YAML, and validation boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 08:47:04 +03:00
eb266ed5ec feat: initialize Go module and project skeleton
- Initialize naviwatcher Go module
- Create directory structure: internal/config/, internal/database/, cmd/naviwatcher/
- Add main.go entry point with flag parsing, config loading placeholder, and graceful shutdown skeleton
- Add dependencies: go-sqlite3, yaml.v3
- Write tests for config loading and flag parsing
- All tests pass, go vet clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 08:33:08 +03:00
44f2652e7c add plan: foundation-layer 2026-05-20 08:27:13 +03:00
21 changed files with 2700 additions and 5 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitignore
.DS_Store
naviwatcher
naviwatcher-linux
naviwatcher-mac
naviwatcher.exe
data/
.claude/
.ralphex/

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.DS_Store
naviwatcher
naviwatcher-linux
naviwatcher-mac
naviwatcher.exe
config.yaml
data/
coverage.out

34
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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 )"

54
cmd/naviwatcher/main.go Normal file
View File

@@ -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
}

View File

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

26
config.yaml.example Normal file
View File

@@ -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

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
naviwatcher:
build:
context: .
dockerfile: Dockerfile
container_name: naviwatcher
ports:
- "8080:8080"
volumes:
- ./data:/app/data
restart: unless-stopped

View File

@@ -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

8
go.mod Normal file
View File

@@ -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
)

6
go.sum Normal file
View File

@@ -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=

112
internal/config/config.go Normal file
View File

@@ -0,0 +1,112 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Config represents the top-level configuration for NaviWatcher.
type Config struct {
Server ServerConfig `yaml:"server"`
Navidrome NavidromeConfig `yaml:"navidrome"`
MusicBrainz MusicBrainzConfig `yaml:"musicbrainz"`
Telegram TelegramConfig `yaml:"telegram"`
Scanner ScannerConfig `yaml:"scanner"`
}
// ServerConfig holds HTTP server settings.
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
// NavidromeConfig holds Subsonic API connection details.
type NavidromeConfig struct {
URL string `yaml:"url"`
User string `yaml:"user"`
Password string `yaml:"password"`
}
// MusicBrainzConfig holds MusicBrainz API settings.
type MusicBrainzConfig struct {
UserAgent string `yaml:"user_agent"`
CacheTTL time.Duration `yaml:"cache_ttl"`
}
// TelegramConfig holds Telegram bot notification settings.
type TelegramConfig struct {
Enabled bool `yaml:"enabled"`
Token string `yaml:"token"`
ChatID string `yaml:"chat_id"`
CronSchedule string `yaml:"cron_schedule"`
}
// ScannerConfig holds scanner engine parameters.
type ScannerConfig struct {
FuzzyThreshold float64 `yaml:"fuzzy_threshold"`
IgnoreBootlegs bool `yaml:"ignore_bootlegs"`
IncludeCompilations bool `yaml:"include_compilations"`
}
// LoadConfig reads a YAML file from path, parses it, applies defaults,
// and validates the configuration.
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
applyDefaults(&cfg)
if err := validate(&cfg); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return &cfg, nil
}
// applyDefaults sets zero-value defaults for optional fields.
func applyDefaults(cfg *Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Scanner.FuzzyThreshold == 0 {
cfg.Scanner.FuzzyThreshold = 0.85
}
}
// validate checks that required fields are set and values are within acceptable ranges.
func validate(cfg *Config) error {
if cfg.Navidrome.URL == "" {
return fmt.Errorf("navidrome.url is required")
}
if cfg.Navidrome.User == "" {
return fmt.Errorf("navidrome.user is required")
}
if cfg.Navidrome.Password == "" {
return fmt.Errorf("navidrome.password is required")
}
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
return fmt.Errorf("server.port must be between 1 and 65535, got %d", cfg.Server.Port)
}
if cfg.Scanner.FuzzyThreshold < 0.0 || cfg.Scanner.FuzzyThreshold > 1.0 {
return fmt.Errorf("scanner.fuzzy_threshold must be between 0.0 and 1.0, got %f", cfg.Scanner.FuzzyThreshold)
}
if cfg.MusicBrainz.UserAgent == "" {
return fmt.Errorf("musicbrainz.user_agent is required")
}
return nil
}

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

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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"`
}

View File

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

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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")
}
}
}