diff --git a/docs/plans/2026-05-20-foundation-layer.md b/docs/plans/2026-05-20-foundation-layer.md index 8ad1c2e..c4156b0 100644 --- a/docs/plans/2026-05-20-foundation-layer.md +++ b/docs/plans/2026-05-20-foundation-layer.md @@ -107,12 +107,12 @@ Build the foundation layer of NaviWatcher: a greenfield Go project with zero exi - [x] run tests — must pass before task 6 ### Task 6: Database layer — CRUD operations for notifications_sent -- [ ] implement `MarkNotificationSent(db *DB, rgid string) error` -- [ ] implement `IsNotificationSent(db *DB, rgid string) (bool, error)` -- [ ] implement `GetUnnotifiedReleases(db *DB) ([]ExternalRelease, error)` — joins external_releases with notifications_sent to find unsent -- [ ] write tests for MarkNotificationSent and IsNotificationSent -- [ ] write tests for GetUnnotifiedReleases (with and without existing notifications) -- [ ] run tests — must pass before task 7 +- [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 - [ ] create `Dockerfile` with multi-stage build: build stage (golang:1.21-alpine) + runtime stage (alpine:latest) diff --git a/internal/database/artist_settings_test.go b/internal/database/artist_settings_test.go index 1e5a750..cbcf92f 100644 --- a/internal/database/artist_settings_test.go +++ b/internal/database/artist_settings_test.go @@ -225,7 +225,7 @@ func TestUpdateArtistSettings_Success(t *testing.T) { // Update only name and monitored. updates := map[string]interface{}{ - "name": "Updated", + "name": "Updated", "monitored": false, } if err := UpdateArtistSettings(db, "artist-1", updates); err != nil { diff --git a/internal/database/database.go b/internal/database/database.go index 3e1c23b..f89509b 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -130,25 +130,25 @@ func (db *DB) markMigrationApplied(name string) error { // 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"` + 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"` + 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"` + RGID string `json:"rgid"` + SentAt time.Time `json:"sent_at"` } diff --git a/internal/database/notifications.go b/internal/database/notifications.go new file mode 100644 index 0000000..abb01aa --- /dev/null +++ b/internal/database/notifications.go @@ -0,0 +1,69 @@ +package database + +import ( + "fmt" +) + +// MarkNotificationSent records that a notification has been sent for the given RGID. +func MarkNotificationSent(db *DB, rgid string) error { + _, err := db.Conn().Exec( + "INSERT OR IGNORE 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 + `) + 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 = ?", rgid, + ).Scan(&sentAt) + if err != nil { + return "", err + } + return sentAt, nil +} diff --git a/internal/database/notifications_test.go b/internal/database/notifications_test.go new file mode 100644 index 0000000..6a0e682 --- /dev/null +++ b/internal/database/notifications_test.go @@ -0,0 +1,265 @@ +package database + +import ( + "database/sql" + "testing" +) + +// 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 := 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_Idempotent verifies that marking the same RGID twice does not fail. +func TestMarkNotificationSent_Idempotent(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + if err := MarkNotificationSent(db, "rgid-1"); err != nil { + t.Fatalf("first MarkNotificationSent() error: %v", err) + } + if err := MarkNotificationSent(db, "rgid-1"); err != nil { + t.Fatalf("second MarkNotificationSent() error: %v", err) + } + + // 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) + } +} + +// 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 := 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"}, + } + + 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"}, + } + + 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"}, + } + + 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() + + 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) + } +}