package notify import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" ) type prefsFakeResolver struct{ uid uuid.UUID } func (f *prefsFakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) { if token == "" { return uuid.Nil, false, nil } return f.uid, true, nil } // reverseEncryptor 是测试用 Encryptor:取反作为 "加密",与 reverseDecryptor 配对。 type reverseEncryptor struct{} func (reverseEncryptor) Encrypt(pt []byte) ([]byte, error) { return reverseBytes(pt), nil } func mountPrefs(t *testing.T, uid uuid.UUID, prefs PrefsRepository) http.Handler { t.Helper() r := chi.NewRouter() NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}). WithPrefs(prefs, reverseEncryptor{}).Mount(r) return r } // noopRepo 满足 inbox Repository(prefs 测试不触达 inbox 端点)。 type noopRepo struct{} func (noopRepo) Insert(context.Context, Message) error { return nil } func (noopRepo) List(context.Context, uuid.UUID, bool, int) ([]Message, error) { return nil, nil } func (noopRepo) CountUnread(context.Context, uuid.UUID) (int, error) { return 0, nil } func (noopRepo) MarkRead(context.Context, uuid.UUID, uuid.UUID) error { return nil } func (noopRepo) MarkAllRead(context.Context, uuid.UUID) error { return nil } func TestPrefsHandler_GetReturnsDefaultsForNewUser(t *testing.T) { uid := uuid.New() h := mountPrefs(t, uid, newFakePrefs()) req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) req.Header.Set("Authorization", "Bearer tok") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) var dto prefsDTO require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto)) require.False(t, dto.EmailEnabled) require.False(t, dto.IMEnabled) require.False(t, dto.IMWebhookURLSet) require.Equal(t, "warning", dto.MinSeverity) } func TestPrefsHandler_GetRequiresAuth(t *testing.T) { h := mountPrefs(t, uuid.New(), newFakePrefs()) req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) // no token rec := httptest.NewRecorder() h.ServeHTTP(rec, req) require.Equal(t, http.StatusUnauthorized, rec.Code) } func TestPrefsHandler_PutPersistsAndEncryptsWebhook(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() h := mountPrefs(t, uid, prefs) body, _ := json.Marshal(prefsUpdateReq{ EmailEnabled: boolPtr(true), IMEnabled: boolPtr(true), IMWebhookURL: strPtr("https://hooks.slack.test/xyz"), MinSeverity: strPtr("error"), }) req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer tok") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) var dto prefsDTO require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto)) require.True(t, dto.EmailEnabled) require.True(t, dto.IMEnabled) require.True(t, dto.IMWebhookURLSet) require.Equal(t, "error", dto.MinSeverity) // 落库的 webhook 应为密文(取反),解回应得回原 URL;明文绝不直接存。 saved, err := prefs.Get(context.Background(), uid) require.NoError(t, err) require.NotEmpty(t, saved.ImWebhookURLEnc) require.NotEqual(t, []byte("https://hooks.slack.test/xyz"), saved.ImWebhookURLEnc) require.Equal(t, "https://hooks.slack.test/xyz", string(reverseBytes(saved.ImWebhookURLEnc))) } func TestPrefsHandler_PutPartialUpdatePreservesWebhook(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning, ImWebhookURLEnc: reverseBytes([]byte("https://existing"))}) h := mountPrefs(t, uid, prefs) // 仅改 email_enabled,省略 im_webhook_url -> 保留原密文。 body, _ := json.Marshal(prefsUpdateReq{EmailEnabled: boolPtr(true)}) req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer tok") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) saved, _ := prefs.Get(context.Background(), uid) require.Equal(t, "https://existing", string(reverseBytes(saved.ImWebhookURLEnc))) require.True(t, saved.EmailEnabled) } func TestPrefsHandler_PutEmptyWebhookClearsIt(t *testing.T) { uid := uuid.New() prefs := newFakePrefs() prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning, ImWebhookURLEnc: reverseBytes([]byte("https://existing"))}) h := mountPrefs(t, uid, prefs) body, _ := json.Marshal(prefsUpdateReq{IMWebhookURL: strPtr("")}) req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer tok") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) saved, _ := prefs.Get(context.Background(), uid) require.Empty(t, saved.ImWebhookURLEnc) } func TestPrefsHandler_PutRejectsBadSeverity(t *testing.T) { uid := uuid.New() h := mountPrefs(t, uid, newFakePrefs()) body, _ := json.Marshal(prefsUpdateReq{MinSeverity: strPtr("catastrophic")}) req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer tok") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) require.Equal(t, http.StatusBadRequest, rec.Code) } func TestPrefsHandler_NotMountedWithoutEncryptor(t *testing.T) { uid := uuid.New() r := chi.NewRouter() // WithPrefs 未调用 -> /prefs 不应被挂载。 NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}).Mount(r) req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) req.Header.Set("Authorization", "Bearer tok") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) require.Equal(t, http.StatusNotFound, rec.Code) } func boolPtr(b bool) *bool { return &b } func strPtr(s string) *string { return &s }