diff --git a/docs/plans/2026-05-20-foundation-layer.md b/docs/plans/2026-05-20-foundation-layer.md index 81387b9..24a91f1 100644 --- a/docs/plans/2026-05-20-foundation-layer.md +++ b/docs/plans/2026-05-20-foundation-layer.md @@ -87,14 +87,14 @@ Build the foundation layer of NaviWatcher: a greenfield Go project with zero exi - [x] run tests — must pass before task 4 ### Task 4: Database layer — CRUD operations for artist_settings -- [ ] implement `GetArtistSettings(db *DB, id string) (*ArtistSettings, error)` -- [ ] implement `SaveArtistSettings(db *DB, settings *ArtistSettings) error` -- [ ] implement `GetAllArtistSettings(db *DB) ([]ArtistSettings, error)` -- [ ] implement `UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) error` -- [ ] write tests for GetArtistSettings (found and not found cases) -- [ ] write tests for SaveArtistSettings (insert and update) -- [ ] write tests for GetAllArtistSettings (empty and populated) -- [ ] run tests — must pass before task 5 +- [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 - [ ] implement `GetExternalRelease(db *DB, rgid string) (*ExternalRelease, error)` diff --git a/internal/database/artist_settings.go b/internal/database/artist_settings.go new file mode 100644 index 0000000..5af3792 --- /dev/null +++ b/internal/database/artist_settings.go @@ -0,0 +1,101 @@ +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 { + // Build the SET clause dynamically from the provided updates. + allowed := map[string]bool{ + "name": true, + "ignore_singles": true, + "ignore_compilations": true, + "monitored": true, + } + + var args []interface{} + setClause := "" + for col, val := range updates { + if !allowed[col] { + return fmt.Errorf("unknown column: %s", col) + } + if setClause != "" { + setClause += ", " + } + setClause += col + " = ?" + args = append(args, val) + } + + if len(args) == 0 { + return fmt.Errorf("no updates provided") + } + + args = append(args, id) + query := fmt.Sprintf("UPDATE artist_settings SET %s WHERE id = ?", 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..1e5a750 --- /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") + } +}