diff --git a/.gitignore b/.gitignore index 59ba284..c02e5f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .DS_Store -naviwatcher +/naviwatcher naviwatcher-linux naviwatcher-mac naviwatcher.exe diff --git a/cmd/naviwatcher/datastore_smoke_test.go b/cmd/naviwatcher/datastore_smoke_test.go new file mode 100644 index 0000000..765d555 --- /dev/null +++ b/cmd/naviwatcher/datastore_smoke_test.go @@ -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) +} diff --git a/config.yaml.example b/config.yaml.example index d1942c0..2c70eee 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -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 diff --git a/docs/plans/2026-07-19-notifier-webui-sync.md b/docs/plans/2026-07-19-notifier-webui-sync.md index c71bf42..536147d 100644 --- a/docs/plans/2026-07-19-notifier-webui-sync.md +++ b/docs/plans/2026-07-19-notifier-webui-sync.md @@ -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