slack5-1770574304/services/preferences-api/internal/api/handlers/preferences_test.go
rdev-worker 5fa5a77bfb
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /implement-feature user-preferences
2026-02-08 18:36:52 +00:00

322 lines
9.8 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770574304/pkg/app"
"git.threesix.ai/jordan/slack5-1770574304/pkg/auth"
"git.threesix.ai/jordan/slack5-1770574304/pkg/logging"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/adapter/memory"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770574304/services/preferences-api/internal/service"
)
func newTestPreferenceHandler() (*Preference, *memory.PreferenceRepository) {
repo := memory.NewPreferenceRepository()
svc := service.NewPreferenceService(repo, logging.Nop())
handler := NewPreference(svc, logging.Nop())
return handler, repo
}
// withAuthUser adds an authenticated user to the request context.
func withAuthUser(r *http.Request, userID string, roles ...string) *http.Request {
user := &auth.User{
ID: userID,
Roles: roles,
}
ctx := auth.SetUser(r.Context(), user)
return r.WithContext(ctx)
}
func TestPreference_Get_SelfAccess(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/user-1", nil)
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatal("expected 'data' field in response")
}
// Should return defaults
if data["theme"] != "system" {
t.Errorf("expected default theme system, got %v", data["theme"])
}
if data["language"] != "en" {
t.Errorf("expected default language en, got %v", data["language"])
}
if data["user_id"] != "user-1" {
t.Errorf("expected user_id user-1, got %v", data["user_id"])
}
// Defaults should not have updated_at
if _, exists := data["updated_at"]; exists && data["updated_at"] != "" {
// updated_at may be present but empty for defaults
}
}
func TestPreference_Get_AdminAccess(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/user-1", nil)
req = withAuthUser(req, "admin-user", "admin")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200 for admin, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Get_Forbidden(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/user-1", nil)
req = withAuthUser(req, "other-user")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_SelfAccess(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
body := UpdatePreferencesRequest{
Theme: "dark",
Language: "fr",
Notifications: UpdateNotificationsRequest{
Email: true,
Push: false,
Digest: "daily",
},
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatal("expected 'data' field in response")
}
if data["theme"] != "dark" {
t.Errorf("expected theme dark, got %v", data["theme"])
}
if data["language"] != "fr" {
t.Errorf("expected language fr, got %v", data["language"])
}
notifications, ok := data["notifications"].(map[string]any)
if !ok {
t.Fatal("expected 'notifications' nested object")
}
if notifications["push"] != false {
t.Errorf("expected notifications.push false, got %v", notifications["push"])
}
if notifications["digest"] != "daily" {
t.Errorf("expected notifications.digest daily, got %v", notifications["digest"])
}
}
func TestPreference_Update_Forbidden(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
body := UpdatePreferencesRequest{
Theme: "dark",
Language: "en",
Notifications: UpdateNotificationsRequest{
Email: true,
Push: true,
Digest: "weekly",
},
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "other-user")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_AdminForbidden(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
body := UpdatePreferencesRequest{
Theme: "dark",
Language: "en",
Notifications: UpdateNotificationsRequest{
Email: true,
Push: true,
Digest: "weekly",
},
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "admin-user", "admin")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Even admins cannot modify another user's preferences
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403 for admin write, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_InvalidBody(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
// Invalid: missing required fields
bodyBytes := []byte(`{"theme": "dark"}`)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid body, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_UnknownFields(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
bodyBytes := []byte(`{"theme":"dark","language":"en","notifications":{"email":true,"push":true,"digest":"weekly"},"unknown_field":"value"}`)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for unknown fields, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Update_InvalidThemeValue(t *testing.T) {
handler, _ := newTestPreferenceHandler()
router := chi.NewRouter()
router.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
bodyBytes := []byte(`{"theme":"invalid","language":"en","notifications":{"email":true,"push":true,"digest":"weekly"}}`)
req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/user-1", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for invalid theme, got %d; body: %s", w.Code, w.Body.String())
}
}
func TestPreference_Get_ExistingPreferences(t *testing.T) {
handler, repo := newTestPreferenceHandler()
// Seed existing preferences
existing := &domain.UserPreferences{
UserID: "user-1",
Theme: domain.ThemeDark,
Language: "ja",
Notifications: domain.NotificationPreferences{
Email: false,
Push: false,
Digest: domain.DigestNone,
},
}
_ = repo.Upsert(nil, existing)
router := chi.NewRouter()
router.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/user-1", nil)
req = withAuthUser(req, "user-1")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d; body: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data := resp["data"].(map[string]any)
if data["theme"] != "dark" {
t.Errorf("expected theme dark, got %v", data["theme"])
}
if data["language"] != "ja" {
t.Errorf("expected language ja, got %v", data["language"])
}
}