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) } } }