Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
7.7 KiB
Go
220 lines
7.7 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
resetRequests(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, _ := testHandlers(t)
|
||
resetRequests(t, h)
|
||
|
||
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, _ := testHandlers(t)
|
||
resetRequests(t, h)
|
||
|
||
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 || len(got[0].Payload.Items) != 2 ||
|
||
!strings.HasSuffix(got[0].Payload.Items[0], "np-play-alice)") {
|
||
t.Errorf("payload = %+v, want two reports, each naming the track and alice", got[0].Payload)
|
||
}
|
||
wantInbox(t, "reporter", inboxOf(t, pool, alice))
|
||
}
|