Files
multica/server/internal/handler/notification_preference_test.go
128d1d2bdd feat(notifications): split mentions out of the comments mute group (#6486)
`new_comment` and `mentioned` shared the `comments` preference group, so
muting comment volume also silenced @-mentions — and because a muted item
is never created, those mentions were lost rather than deferred. In
agent-heavy workspaces the only lever that reduces inbox noise also
removed the signal the inbox exists for.

`mentioned` now maps to its own `mentions` group. Preferences are stored
sparse (a missing key means "all"), so existing `{"comments":"muted"}`
rows default the new group to "all" with no migration: muting comments is
a volume decision, not a decision to become unreachable by name.

The split also aligns the global setting with behavior the platform
already had — per-issue unsubscribe keeps delivering direct mentions, and
mention delivery bypasses the subscriber table entirely.

Closes #6468

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-06 14:17:19 +08:00

179 lines
5.3 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/multica-ai/multica/server/internal/middleware"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func notificationPreferenceRequest(
t *testing.T,
method string,
preferences map[string]string,
) *http.Request {
t.Helper()
member, err := testHandler.Queries.GetMemberByUserAndWorkspace(
context.Background(),
db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(testUserID),
WorkspaceID: parseUUID(testWorkspaceID),
},
)
if err != nil {
t.Fatalf("load test member: %v", err)
}
req := newRequest(method, "/api/notification-preferences", map[string]any{
"preferences": preferences,
})
return req.WithContext(
middleware.SetMemberContext(req.Context(), testWorkspaceID, member),
)
}
func TestPatchNotificationPreferencesMergesWithoutReplacing(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
if _, err := testPool.Exec(ctx, `
DELETE FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2
`, testWorkspaceID, testUserID); err != nil {
t.Fatalf("reset notification preference: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `
DELETE FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2
`, testWorkspaceID, testUserID)
})
putRecorder := httptest.NewRecorder()
testHandler.UpdateNotificationPreferences(
putRecorder,
notificationPreferenceRequest(t, http.MethodPut, map[string]string{
"status_changes": "muted",
}),
)
if putRecorder.Code != http.StatusOK {
t.Fatalf("seed preference: status=%d body=%s", putRecorder.Code, putRecorder.Body.String())
}
patchRecorder := httptest.NewRecorder()
testHandler.PatchNotificationPreferences(
patchRecorder,
notificationPreferenceRequest(t, http.MethodPatch, map[string]string{
"comments": "muted",
}),
)
if patchRecorder.Code != http.StatusOK {
t.Fatalf("patch preference: status=%d body=%s", patchRecorder.Code, patchRecorder.Body.String())
}
var response struct {
WorkspaceID string `json:"workspace_id"`
Preferences map[string]string `json:"preferences"`
}
if err := json.NewDecoder(patchRecorder.Body).Decode(&response); err != nil {
t.Fatalf("decode patch response: %v", err)
}
if response.WorkspaceID != testWorkspaceID {
t.Fatalf("workspace_id = %q, want %q", response.WorkspaceID, testWorkspaceID)
}
if response.Preferences["status_changes"] != "muted" {
t.Fatalf("status_changes was replaced: %#v", response.Preferences)
}
if response.Preferences["comments"] != "muted" {
t.Fatalf("comments patch missing: %#v", response.Preferences)
}
enableRecorder := httptest.NewRecorder()
testHandler.PatchNotificationPreferences(
enableRecorder,
notificationPreferenceRequest(t, http.MethodPatch, map[string]string{
"status_changes": "all",
}),
)
if enableRecorder.Code != http.StatusOK {
t.Fatalf("enable preference: status=%d body=%s", enableRecorder.Code, enableRecorder.Body.String())
}
var persisted []byte
if err := testPool.QueryRow(ctx, `
SELECT preferences
FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2
`, testWorkspaceID, testUserID).Scan(&persisted); err != nil {
t.Fatalf("load persisted preference: %v", err)
}
var preferences map[string]string
if err := json.Unmarshal(persisted, &preferences); err != nil {
t.Fatalf("decode persisted preference: %v", err)
}
if preferences["status_changes"] != "all" || preferences["comments"] != "muted" {
t.Fatalf("unexpected persisted preferences: %#v", preferences)
}
}
// TestPatchNotificationPreferencesAcceptsMentions covers the group added by
// #6468. The whitelist rejects unknown keys, so a client shipped ahead of the
// server would 400 on this key — the reason the server must deploy first.
func TestPatchNotificationPreferencesAcceptsMentions(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `
DELETE FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2
`, testWorkspaceID, testUserID)
})
recorder := httptest.NewRecorder()
testHandler.PatchNotificationPreferences(
recorder,
notificationPreferenceRequest(t, http.MethodPatch, map[string]string{
"mentions": "muted",
}),
)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response struct {
Preferences map[string]string `json:"preferences"`
}
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Preferences["mentions"] != "muted" {
t.Fatalf("mentions not persisted: %#v", response.Preferences)
}
}
func TestPatchNotificationPreferencesRejectsUnknownGroups(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
recorder := httptest.NewRecorder()
testHandler.PatchNotificationPreferences(
recorder,
notificationPreferenceRequest(t, http.MethodPatch, map[string]string{
"unknown_group": "muted",
}),
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}