Files
minstrel/internal/api/notify_producers_test.go
T
bvandeusenandClaude Opus 5.5 1e9408c835
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(notifications): library health reaches admins, coalesced (#5341)
- A failed scan run sends scan_failed. Each failure adds to the count and
  the notice shows the latest error. A scan cut short by shutdown says
  nothing.
- Marking tracks missing sends tracks_missing with a running count.
- A duplicate sweep that proposes a group it had not proposed before
  sends duplicates_found, counting everything awaiting review. A sweep
  that only re-finds known groups stays quiet, so a read notice isn't
  repeated every sweep (CountDuplicateGroupsDetectedSince).
- A playback-error report sends playback_errors, counting the unresolved
  errors (CountUnresolvedPlaybackErrors).

The library package gets its notifier as a package-level SetNotifier
beside SetEventBus, for the same reason the bus is package-level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:20:33 -04:00

239 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// inboxItem is one thing the notifier left for one user: kind and decoded payload,
// newest first.
type inboxItem struct {
Kind string
Payload notifications.Payload
}
func inboxOf(t *testing.T, pool *pgxpool.Pool, user dbq.User) []inboxItem {
t.Helper()
rows, err := dbq.New(pool).ListNotifications(context.Background(), dbq.ListNotificationsParams{
UserID: user.ID, PageLimit: 100,
})
if err != nil {
t.Fatalf("list notifications: %v", err)
}
out := make([]inboxItem, 0, len(rows))
for _, r := range rows {
var p notifications.Payload
if err := json.Unmarshal(r.Payload, &p); err != nil {
t.Fatalf("payload %s: %v", r.Payload, err)
}
out = append(out, inboxItem{Kind: r.Kind, Payload: p})
}
return out
}
func wantInbox(t *testing.T, who string, got []inboxItem, kinds ...notifications.Kind) {
t.Helper()
if len(got) != len(kinds) {
t.Fatalf("%s inbox = %+v, want kinds %v", who, got, kinds)
}
for i, k := range kinds {
if got[i].Kind != string(k) {
t.Errorf("%s inbox[%d] = %q, want %q", who, i, got[i].Kind, k)
}
}
}
// newApprovingLidarrStub answers every Lidarr call an approval makes.
func newApprovingLidarrStub(t *testing.T) *httptest.Server {
t.Helper()
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
return stub
}
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
alice := seedUser(t, pool, "np-alice", "pw", false)
admin := seedUser(t, pool, "np-admin", "pw", true)
selfAdmin := seedUser(t, pool, "np-self", "pw", true)
rv := createArtistRequest(t, h, alice, "np-mbid-1", "Pending Band")
// The same request again dedups into the first and is not announced twice.
createArtistRequest(t, h, alice, "np-mbid-1", "Pending Band")
got := inboxOf(t, pool, admin)
wantInbox(t, "admin", got, notifications.KindRequestPending)
if got[0].Payload.RequestID != uuidToString(rv.ID) || got[0].Payload.Name != "Pending Band" || got[0].Payload.Actor != alice.Username {
t.Errorf("pending payload = %+v", got[0].Payload)
}
wantInbox(t, "requester", inboxOf(t, pool, alice))
// An admin's own request goes to the other admins, never back to them.
createArtistRequest(t, h, selfAdmin, "np-mbid-2", "Own Band")
wantInbox(t, "self-admin", inboxOf(t, pool, selfAdmin), notifications.KindRequestPending)
if n := len(inboxOf(t, pool, admin)); n != 2 {
t.Errorf("admin inbox = %d, want 2 (alice's and the self-admin's)", n)
}
}
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
alice := seedUser(t, h.pool, "np-auto", "pw", false)
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
if _, err := h.pool.Exec(context.Background(),
"UPDATE users SET auto_approve_requests = true WHERE id = $1", alice.ID); err != nil {
t.Fatalf("set auto_approve: %v", err)
}
alice, err := dbq.New(h.pool).GetUserByID(context.Background(), alice.ID)
if err != nil {
t.Fatalf("reload user: %v", err)
}
rv := createArtistRequest(t, h, alice, "np-auto-mbid", "Auto Band")
if rv.Status != "approved" {
t.Fatalf("status = %q, want approved — the stub should accept the add", rv.Status)
}
wantInbox(t, "admin", inboxOf(t, h.pool, admin))
wantInbox(t, "requester", inboxOf(t, h.pool, alice))
}
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
approved := seedPendingArtistRequest(t, h, alice, "np-dec-1", "Yes Band")
rejected := seedPendingArtistRequest(t, h, alice, "np-dec-2", "No Band")
own := seedPendingArtistRequest(t, h, admin, "np-dec-3", "Own Band")
for _, c := range []struct {
id string
verb string
body []byte
}{
{uuidToString(approved.ID), "approve", nil},
{uuidToString(rejected.ID), "reject", []byte(`{"notes":"Only a live bootleg exists"}`)},
{uuidToString(own.ID), "approve", nil},
} {
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+c.id+"/"+c.verb, c.body, admin)
if w.Code != http.StatusOK {
t.Fatalf("%s %s: status = %d; body = %s", c.verb, c.id, w.Code, w.Body.String())
}
}
got := inboxOf(t, h.pool, alice)
wantInbox(t, "requester", got, notifications.KindRequestRejected, notifications.KindRequestApproved)
if got[0].Payload.Name != "No Band" || got[0].Payload.Reason != "Only a live bootleg exists" {
t.Errorf("rejected payload = %+v", got[0].Payload)
}
if got[1].Payload.Name != "Yes Band" || got[1].Payload.Reason != "" {
t.Errorf("approved payload = %+v", got[1].Payload)
}
// The admin decided their own request: no notice. alice's two pending
// notices are all the admin holds.
for _, it := range inboxOf(t, h.pool, admin) {
if it.Kind != string(notifications.KindRequestPending) {
t.Errorf("admin inbox holds %q, want only request_pending", it.Kind)
}
}
}
func TestNotify_FlagReachesAdminsButNotTheFlagger(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "np-flag-alice", "pw", false)
admin := seedUser(t, pool, "np-flag-admin", "pw", true)
other := seedUser(t, pool, "np-flag-other", "pw", true)
track := seedQuarantineTrack(t, h, "np")
w := doFlag(h, alice, fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(track.ID)))
if w.Code != http.StatusCreated {
t.Fatalf("flag: status = %d; body = %s", w.Code, w.Body.String())
}
got := inboxOf(t, pool, admin)
wantInbox(t, "admin", got, notifications.KindQuarantineFlagged)
if p := got[0].Payload; p.Name != "Q Artist np – Q Track np" || p.Actor != alice.Username || p.Reason != "bad rip" {
t.Errorf("flag payload = %+v", p)
}
wantInbox(t, "flagger", inboxOf(t, pool, alice))
// An admin flagging a track tells the other admins only.
track2 := seedQuarantineTrack(t, h, "np2")
w = doFlag(h, admin, fmt.Sprintf(`{"track_id":%q,"reason":"other"}`, uuidToString(track2.ID)))
if w.Code != http.StatusCreated {
t.Fatalf("admin flag: status = %d; body = %s", w.Code, w.Body.String())
}
if n := len(inboxOf(t, pool, admin)); n != 1 {
t.Errorf("flagging admin inbox = %d, want 1 (alice's flag only)", n)
}
if n := len(inboxOf(t, pool, other)); n != 2 {
t.Errorf("other admin inbox = %d, want 2", n)
}
}
func TestQuarantineReasonLabel(t *testing.T) {
for in, want := range map[string]string{
"bad_rip": "bad rip", "wrong_file": "wrong file", "wrong_tags": "wrong tags",
"duplicate": "duplicate", "other": "", "": "",
} {
if got := quarantineReasonLabel(in); got != want {
t.Errorf("quarantineReasonLabel(%q) = %q, want %q", in, got, want)
}
}
}
func TestNotify_PlaybackErrorsCoalesceIntoOneCount(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "np-play-alice", "pw", false)
admin := seedUser(t, pool, "np-play-admin", "pw", true)
track := seedQuarantineTrack(t, h, "play")
for i := 0; i < 2; i++ {
body := fmt.Sprintf(`{"track_id":%q,"kind":"load_failed","client_id":"web-%d"}`, uuidToString(track.ID), i)
req := withUser(httptest.NewRequest(http.MethodPost, "/api/playback-errors", strings.NewReader(body)), alice)
w := httptest.NewRecorder()
h.handleReportPlaybackError(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("report %d: status = %d; body = %s", i, w.Code, w.Body.String())
}
}
got := inboxOf(t, pool, admin)
wantInbox(t, "admin", got, notifications.KindPlaybackErrors)
if got[0].Payload.Count != 2 {
t.Errorf("count = %d, want 2 unresolved", got[0].Payload.Count)
}
wantInbox(t, "reporter", inboxOf(t, pool, alice))
}