feat(notifications): requests and flags reach the people who act on them (#5340)

- A new request still pending after any auto-approval notifies the
  admins (request_pending), but not the requester if they are an admin.
  A request that dedups into one already in flight is not announced again.
- Approving or rejecting a request notifies the requester, and a
  rejection carries the admin's notes as the reason. An admin deciding
  their own request gets nothing.
- The reconciler notifies the requester when their request arrives
  (request_completed), linking the matched album or artist.
- A request the re-acquisition sweeper files and cannot approve itself
  notifies the admins.
- A quarantine flag notifies every admin except the flagger, naming the
  track, the flagger and the reason.

lidarrrequests.Service.CreateTracked reports whether a request was
inserted or deduped; Create wraps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:17:11 -04:00
co-authored by Claude Opus 5.5
parent 94ef8c1887
commit 069baeb14d
13 changed files with 486 additions and 33 deletions
+212
View File
@@ -0,0 +1,212 @@
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)
}
}
}