release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m40s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android
queues "mark all read" for replay when offline, and a replay landing later
must not mark notices that arrived in between, which the user never saw.
A coalesced notice updated since then has a newer created_at, so it stays
unread. An empty body still marks everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
10 KiB
Go
265 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// A "mark all read" replayed from an offline queue carries the moment the
|
|
// user asked; what arrived after it stays unread.
|
|
func TestMyNotifications_ReadAllUpToLeavesLaterOnesUnread(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
alice := seedUser(t, pool, "notif-upto", "pw", false)
|
|
r := notificationsRouter(h)
|
|
|
|
notifyN(t, pool, alice, 1)
|
|
var page notificationsPageResp
|
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
|
|
seen := page.Items[0].CreatedAt
|
|
notifyN(t, pool, alice, 1)
|
|
|
|
body := `{"up_to":"` + seen.Format(time.RFC3339Nano) + `"}`
|
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", body, nil); code != http.StatusNoContent {
|
|
t.Fatalf("read-all up_to = %d", 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 (the one that arrived later)", count.UnreadCount)
|
|
}
|
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", `{"up_to":"yesterday"}`, nil); code != http.StatusBadRequest {
|
|
t.Errorf("malformed up_to = %d, want 400", 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)
|
|
}
|
|
}
|