release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 4m58s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
M489 step 2.
- GET /api/me/notifications?limit&before: newest first, keyset-paged on
(created_at, id) with an opaque cursor, plus the unread count.
- GET /api/me/notifications/unread-count: the badge's cheap call.
- POST /api/me/notifications/{id}/read and /read-all. Mark-read is
idempotent; another user's id is a 404, the same as a malformed one.
- GET/PUT /api/me/notification-settings: every kind the caller can receive
(admin kinds only for admins) with inbox/phone/email. PUT is partial, so an
offline replay sends only what was touched, and a batch with any invalid
change applies nothing. The response says whether email can be delivered
at all: no address on file, or SMTP not configured. A failed SMTP config
read is a 500, not "not configured".
notifications.Render turns kind + payload into title, body and link on the
server, so the web inbox, the Android inbox, the phone's shade and the email
digest all say the same thing. mailer.Configured lifts Send's readiness
check out so settings can report it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.8 KiB
Go
237 lines
8.8 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
|
)
|
|
|
|
func notificationsRouter(h *handlers) chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/api/me/notifications", h.handleListMyNotifications)
|
|
r.Get("/api/me/notifications/unread-count", h.handleMyUnreadNotificationCount)
|
|
r.Post("/api/me/notifications/read-all", h.handleMarkAllMyNotificationsRead)
|
|
r.Post("/api/me/notifications/{id}/read", h.handleMarkMyNotificationRead)
|
|
r.Get("/api/me/notification-settings", h.handleGetMyNotificationSettings)
|
|
r.Put("/api/me/notification-settings", h.handlePutMyNotificationSettings)
|
|
return r
|
|
}
|
|
|
|
func callAs(t *testing.T, r chi.Router, user dbq.User, method, path, body string, out any) int {
|
|
t.Helper()
|
|
req := withUser(httptest.NewRequest(method, path, bytes.NewBufferString(body)), user)
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
if out != nil && rec.Code == http.StatusOK {
|
|
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
|
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
|
}
|
|
}
|
|
return rec.Code
|
|
}
|
|
|
|
func notifyN(t *testing.T, pool *pgxpool.Pool, user dbq.User, n int) {
|
|
t.Helper()
|
|
nt := notifications.New(pool, nil, nil)
|
|
for i := 0; i < n; i++ {
|
|
if err := nt.Notify(context.Background(), notifications.KindRequestCompleted,
|
|
notifications.ToUser(user.ID), notifications.Payload{Name: "Album", AlbumID: "al-1"}.Map()); err != nil {
|
|
t.Fatalf("notify: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMyNotifications_ListRendersPagesAndCounts(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
alice := seedUser(t, pool, "notif-alice", "pw", false)
|
|
bob := seedUser(t, pool, "notif-bob", "pw", false)
|
|
notifyN(t, pool, alice, 3)
|
|
notifyN(t, pool, bob, 1)
|
|
r := notificationsRouter(h)
|
|
|
|
var page notificationsPageResp
|
|
if code := callAs(t, r, alice, http.MethodGet, "/api/me/notifications?limit=2", "", &page); code != http.StatusOK {
|
|
t.Fatalf("list = %d", code)
|
|
}
|
|
if len(page.Items) != 2 || page.UnreadCount != 3 || page.NextBefore == "" {
|
|
t.Fatalf("first page = %d items, unread %d, next %q; want 2, 3, a cursor", len(page.Items), page.UnreadCount, page.NextBefore)
|
|
}
|
|
first := page.Items[0]
|
|
if first.Title != "Now in your library" || first.Body != "Album has arrived." || first.Link != "/albums/al-1" || first.ReadAt != nil {
|
|
t.Errorf("rendered item = %+v", first)
|
|
}
|
|
|
|
var rest notificationsPageResp
|
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications?limit=2&before="+page.NextBefore, "", &rest)
|
|
if len(rest.Items) != 1 || rest.NextBefore != "" {
|
|
t.Errorf("second page = %d items, next %q; want 1 and no cursor", len(rest.Items), rest.NextBefore)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, it := range append(page.Items, rest.Items...) {
|
|
if seen[it.ID] {
|
|
t.Errorf("item %s on two pages", it.ID)
|
|
}
|
|
seen[it.ID] = true
|
|
}
|
|
|
|
for _, bad := range []string{"?limit=0", "?limit=x", "?before=nonsense"} {
|
|
if code := callAs(t, r, alice, http.MethodGet, "/api/me/notifications"+bad, "", nil); code != http.StatusBadRequest {
|
|
t.Errorf("GET %s = %d, want 400", bad, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMyNotifications_MarkReadIsOwnerScopedAndCountsDown(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
alice := seedUser(t, pool, "notif-owner", "pw", false)
|
|
mallory := seedUser(t, pool, "notif-mallory", "pw", false)
|
|
notifyN(t, pool, alice, 2)
|
|
r := notificationsRouter(h)
|
|
|
|
var page notificationsPageResp
|
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
|
|
id := page.Items[0].ID
|
|
|
|
if code := callAs(t, r, mallory, http.MethodPost, "/api/me/notifications/"+id+"/read", "", nil); code != http.StatusNotFound {
|
|
t.Errorf("another user's mark-read = %d, want 404", code)
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/"+id+"/read", "", nil); code != http.StatusNoContent {
|
|
t.Errorf("mark-read #%d = %d, want 204 (repeat is harmless)", i+1, code)
|
|
}
|
|
}
|
|
var count unreadCountResp
|
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
|
|
if count.UnreadCount != 1 {
|
|
t.Errorf("unread = %d, want 1", count.UnreadCount)
|
|
}
|
|
|
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", "", nil); code != http.StatusNoContent {
|
|
t.Fatalf("read-all = %d", code)
|
|
}
|
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
|
|
if count.UnreadCount != 0 {
|
|
t.Errorf("unread after read-all = %d, want 0", count.UnreadCount)
|
|
}
|
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/not-a-uuid/read", "", nil); code != http.StatusNotFound {
|
|
t.Errorf("malformed id = %d, want 404", code)
|
|
}
|
|
}
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
func setSMTP(t *testing.T, pool *pgxpool.Pool, enabled bool) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
prev, err := q.GetSMTPConfig(ctx)
|
|
if err != nil {
|
|
t.Fatalf("read smtp: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = q.UpdateSMTPConfig(context.Background(), dbq.UpdateSMTPConfigParams{
|
|
Enabled: prev.Enabled, Host: prev.Host, Port: prev.Port, Username: prev.Username,
|
|
Password: prev.Password, FromAddress: prev.FromAddress, FromName: prev.FromName, UseTls: prev.UseTls,
|
|
})
|
|
})
|
|
if err := q.UpdateSMTPConfig(ctx, dbq.UpdateSMTPConfigParams{
|
|
Enabled: enabled, Host: "smtp.example.com", Port: 587, FromAddress: "minstrel@example.com", FromName: "Minstrel", UseTls: true,
|
|
}); err != nil {
|
|
t.Fatalf("set smtp: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMyNotificationSettings_DefaultsRoundTripAndAdminKinds(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
setSMTP(t, pool, true)
|
|
user := seedUser(t, pool, "notif-settings", "pw", false)
|
|
admin := seedUser(t, pool, "notif-settings-admin", "pw", true)
|
|
r := notificationsRouter(h)
|
|
|
|
var s notificationSettingsResp
|
|
if code := callAs(t, r, user, http.MethodGet, "/api/me/notification-settings", "", &s); code != http.StatusOK {
|
|
t.Fatalf("get = %d", code)
|
|
}
|
|
for _, k := range s.Kinds {
|
|
if k.AdminOnly {
|
|
t.Errorf("non-admin offered admin kind %s", k.Kind)
|
|
}
|
|
}
|
|
if s.EmailAvailable || s.EmailUnavailableReason != "no_address" {
|
|
t.Errorf("no address on file: email_available=%v reason=%q", s.EmailAvailable, s.EmailUnavailableReason)
|
|
}
|
|
|
|
// A partial change touches only what it names.
|
|
body := `{"kinds":[{"kind":"request_completed","email":false}]}`
|
|
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", body, &s); code != http.StatusOK {
|
|
t.Fatalf("put = %d", code)
|
|
}
|
|
for _, k := range s.Kinds {
|
|
if k.Kind == notifications.KindRequestCompleted && (k.Email || !k.Inbox || !k.Phone) {
|
|
t.Errorf("after PUT request_completed = %+v, want inbox+phone on, email off", k)
|
|
}
|
|
if k.Kind == notifications.KindRequestApproved && !k.Email {
|
|
t.Errorf("an untouched kind changed: %+v", k)
|
|
}
|
|
}
|
|
|
|
// A non-admin can't set an admin kind; nothing in the batch is applied.
|
|
bad := `{"kinds":[{"kind":"request_approved","inbox":false},{"kind":"tracks_missing","inbox":false}]}`
|
|
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", bad, nil); code != http.StatusBadRequest {
|
|
t.Errorf("non-admin setting an admin kind = %d, want 400", code)
|
|
}
|
|
callAs(t, r, user, http.MethodGet, "/api/me/notification-settings", "", &s)
|
|
for _, k := range s.Kinds {
|
|
if k.Kind == notifications.KindRequestApproved && !k.Inbox {
|
|
t.Error("a refused batch was partly applied")
|
|
}
|
|
}
|
|
if code := callAs(t, r, user, http.MethodPut, "/api/me/notification-settings", `{"kinds":[{"kind":"bogus"}]}`, nil); code != http.StatusBadRequest {
|
|
t.Errorf("unknown kind = %d, want 400", code)
|
|
}
|
|
|
|
// An admin with an address and SMTP on sees the admin kinds and can email.
|
|
if _, err := pool.Exec(context.Background(), `UPDATE users SET email = 'admin@example.com' WHERE id = $1`, admin.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin.Email = strPtr("admin@example.com")
|
|
callAs(t, r, admin, http.MethodGet, "/api/me/notification-settings", "", &s)
|
|
if !s.EmailAvailable {
|
|
t.Errorf("admin with address + SMTP: email unavailable (%q)", s.EmailUnavailableReason)
|
|
}
|
|
var adminKinds int
|
|
for _, k := range s.Kinds {
|
|
if k.AdminOnly {
|
|
adminKinds++
|
|
if k.Kind == notifications.KindTracksMissing && k.Email {
|
|
t.Error("tracks_missing should default to email off")
|
|
}
|
|
}
|
|
}
|
|
if adminKinds == 0 {
|
|
t.Error("admin was offered no admin kinds")
|
|
}
|
|
}
|
|
|
|
func TestMyNotificationSettings_EmailUnavailableWhenSMTPIsOff(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
setSMTP(t, pool, false)
|
|
user := seedUser(t, pool, "notif-nosmtp", "pw", false)
|
|
user.Email = strPtr("someone@example.com")
|
|
|
|
var s notificationSettingsResp
|
|
callAs(t, notificationsRouter(h), user, http.MethodGet, "/api/me/notification-settings", "", &s)
|
|
if s.EmailAvailable || s.EmailUnavailableReason != "smtp_not_configured" {
|
|
t.Errorf("SMTP off: email_available=%v reason=%q", s.EmailAvailable, s.EmailUnavailableReason)
|
|
}
|
|
}
|