musicbrainz-provider #2

Merged
Mrixs merged 72 commits from musicbrainz-provider into master 2026-08-05 20:19:16 +00:00
4 changed files with 171 additions and 5 deletions
Showing only changes of commit 1a644dede7 - Show all commits

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.DS_Store
naviwatcher
/naviwatcher
naviwatcher-linux
naviwatcher-mac
naviwatcher.exe

View File

@@ -0,0 +1,159 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
"naviwatcher/internal/notifier"
"naviwatcher/internal/scanner"
"naviwatcher/internal/web"
)
// TestDataFlowSmoke seeds an in-memory DB with a monitored artist, one local
// album, and a missing external release, then exercises the full
// scan -> notify -> web pipeline end to end:
// - ScanAll reports the missing release
// - NotifyOnce (with a stub sender) sends exactly the missing release and
// marks it as sent, so a second run reports nothing
// - The Web UI dashboard requires auth and renders the artist + missing count,
// and the artist detail page renders the missing release
//
// This is the acceptance smoke check for Task 9.
func TestDataFlowSmoke(t *testing.T) {
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("open :memory: db: %v", err)
}
defer db.Close()
ctx := context.Background()
// Seed: one monitored artist with one local album ...
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: "ar1",
Name: "Pink Floyd",
MBID: "abcdef",
Monitored: true,
}); err != nil {
t.Fatalf("seed artist: %v", err)
}
if err := database.SaveLocalAlbum(db, &database.LocalAlbum{
ID: "al1",
ArtistID: "ar1",
Title: "The Wall",
}); err != nil {
t.Fatalf("seed local album: %v", err)
}
// ... and a missing external release (not present locally).
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
RGID: "rg-missing",
ArtistID: "ar1",
Title: "Animals",
Type: "Album",
ReleaseDate: "1977-01-01",
}); err != nil {
t.Fatalf("seed external release: %v", err)
}
// 1) ScanAll should surface exactly the missing release.
missing, err := scanner.ScanAll(ctx, db, 0.85)
if err != nil {
t.Fatalf("ScanAll: %v", err)
}
if len(missing) != 1 {
t.Fatalf("expected 1 missing release, got %d", len(missing))
}
if missing[0].RGID != "rg-missing" || missing[0].Title != "Animals" {
t.Fatalf("unexpected missing release: %+v", missing[0])
}
// 2) Notifier: stub sender, first run notifies 1, second run notifies 0.
var sent []string
stub := stubSender{onSend: func(msg string) error {
sent = append(sent, msg)
return nil
}}
tgCfg := config.TelegramConfig{Enabled: true}
n1, err := notifier.NotifyOnce(ctx, db, stub, tgCfg, "http://localhost:8080")
if err != nil {
t.Fatalf("NotifyOnce #1: %v", err)
}
if n1 != 1 {
t.Fatalf("expected NotifyOnce to send 1, got %d", n1)
}
if len(sent) != 1 || !strings.Contains(sent[0], "Animals") {
t.Fatalf("digest missing expected content: %v", sent)
}
n2, err := notifier.NotifyOnce(ctx, db, stub, tgCfg, "http://localhost:8080")
if err != nil {
t.Fatalf("NotifyOnce #2: %v", err)
}
if n2 != 0 {
t.Fatalf("expected second NotifyOnce to send 0 (already sent), got %d", n2)
}
// 3) Web UI: dashboard requires basic auth and renders the artist.
srvCfg := &config.ServerConfig{
Host: "localhost",
Port: 0,
Username: "admin",
Password: "secret",
}
srv := web.NewServer(srvCfg, db, "http://localhost:8080")
// Unauthenticated -> 401.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for unauthenticated dashboard, got %d", rec.Code)
}
// Authenticated -> 200 with the artist name and missing count.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("admin", "secret")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for authenticated dashboard, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Pink Floyd") {
t.Fatalf("dashboard did not render artist name; body head:\n%s", body[:min(400, len(body))])
}
// Artist detail page renders the missing release.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/artist/ar1", nil)
req.SetBasicAuth("admin", "secret")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for artist page, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Animals") {
t.Fatalf("artist page did not render missing release 'Animals'")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// stubSender is a test double for notifier.Sender.
type stubSender struct {
onSend func(message string) error
}
func (s stubSender) Send(_ context.Context, message string) error {
return s.onSend(message)
}

View File

@@ -22,3 +22,10 @@ telegram:
scanner:
fuzzy_threshold: 0.85
# Periodic sync+scan pipeline: how often NaviWatcher pulls artists/albums from
# Navidrome, resolves MusicBrainz discographies, and re-runs the scanner.
# Accepts any duration Go's time.ParseDuration understands (e.g. "6h", "30m").
# Defaults to 6h when omitted.
sync:
interval: 6h

View File

@@ -132,10 +132,10 @@ name collisions.)
- [x] run tests - must pass before task 9
### Task 9: Verify acceptance criteria
- [ ] run full suite `go test ./...` — all pass
- [ ] run `go vet ./...` and `go build -o naviwatcher` — clean
- [ ] verify scan→notify→web data flow with a seeded `:memory:`/file DB smoke check
- [ ] verify config.yaml.example documents new `sync_interval` field
- [x] run full suite `go test ./...` — all pass
- [x] run `go vet ./...` and `go build -o naviwatcher` — clean
- [x] verify scan→notify→web data flow with a seeded `:memory:`/file DB smoke check
- [x] verify config.yaml.example documents new `sync_interval` field
### Task 10: Update documentation
- [ ] add a short "How it works now" note to README/CLAUDE.md if present