package handlers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "sync" "testing" "github.com/go-chi/chi/v5" "git.threesix.ai/jordan/slack5-1770541397/pkg/auth" "git.threesix.ai/jordan/slack5-1770541397/pkg/httperror" "git.threesix.ai/jordan/slack5-1770541397/pkg/logging" "git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/domain" "git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/port" "git.threesix.ai/jordan/slack5-1770541397/services/preferences-api/internal/service" ) // mockPreferencesRepository implements port.PreferencesRepository for handler tests. type mockPreferencesRepository struct { mu sync.RWMutex store map[domain.UserID]*domain.UserPreferences } var _ port.PreferencesRepository = (*mockPreferencesRepository)(nil) func newMockPreferencesRepository() *mockPreferencesRepository { return &mockPreferencesRepository{ store: make(map[domain.UserID]*domain.UserPreferences), } } func (m *mockPreferencesRepository) Get(ctx context.Context, userID domain.UserID) (*domain.UserPreferences, error) { m.mu.RLock() defer m.mu.RUnlock() prefs, ok := m.store[userID] if !ok { return nil, domain.ErrPreferencesNotFound } copy := *prefs return ©, nil } func (m *mockPreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error { m.mu.Lock() defer m.mu.Unlock() copy := *prefs m.store[prefs.UserID] = © return nil } func newTestPreferencesHandler() (*Preferences, *mockPreferencesRepository) { repo := newMockPreferencesRepository() svc := service.NewPreferencesService(repo, logging.Nop()) handler := NewPreferences(svc, logging.Nop()) return handler, repo } // withAuthUser creates an HTTP handler that injects an auth user into the context. func withAuthUser(userID string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := auth.SetUser(r.Context(), &auth.User{ID: userID}) next.ServeHTTP(w, r.WithContext(ctx)) } } func TestPreferences_Get(t *testing.T) { handler, repo := newTestPreferencesHandler() // Seed stored preferences for user-456 repo.store["user-456"] = &domain.UserPreferences{ UserID: "user-456", Preferences: domain.Preferences{ Theme: "dark", Language: "fr", Notifications: domain.NotificationPreferences{ Email: false, Push: true, SMS: true, }, }, } tests := []struct { name string pathUserID string authUserID string wantStatus int wantTheme string }{ { name: "returns defaults for new user", pathUserID: "user-123", authUserID: "user-123", wantStatus: http.StatusOK, wantTheme: "system", }, { name: "returns stored preferences", pathUserID: "user-456", authUserID: "user-456", wantStatus: http.StatusOK, wantTheme: "dark", }, { name: "returns forbidden for different user", pathUserID: "user-456", authUserID: "user-other", wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := chi.NewRouter() r.Get("/api/preferences-api/preferences/{user_id}", withAuthUser(tt.authUserID, func(w http.ResponseWriter, r *http.Request) { if err := handler.Get(w, r); err != nil { writeErrorStatus(w, err) return } })) req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+tt.pathUserID, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code) } if tt.wantStatus == http.StatusOK && tt.wantTheme != "" { 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") } prefs, ok := data["preferences"].(map[string]any) if !ok { t.Fatal("expected 'preferences' field in data") } if prefs["theme"] != tt.wantTheme { t.Errorf("expected theme '%s', got '%v'", tt.wantTheme, prefs["theme"]) } } }) } } func TestPreferences_Put(t *testing.T) { handler, _ := newTestPreferencesHandler() tests := []struct { name string pathUserID string authUserID string body any wantStatus int }{ { name: "sets preferences successfully", pathUserID: "user-123", authUserID: "user-123", body: PutPreferencesRequest{ Preferences: PreferencesPayload{ Theme: "dark", Language: "en", Notifications: &NotificationPreferencesPayload{ Email: true, Push: false, SMS: false, }, }, }, wantStatus: http.StatusOK, }, { name: "returns forbidden for different user", pathUserID: "user-123", authUserID: "user-other", body: PutPreferencesRequest{ Preferences: PreferencesPayload{ Theme: "dark", Language: "en", }, }, wantStatus: http.StatusForbidden, }, { name: "rejects invalid theme", pathUserID: "user-123", authUserID: "user-123", body: PutPreferencesRequest{ Preferences: PreferencesPayload{ Theme: "neon", Language: "en", }, }, wantStatus: http.StatusBadRequest, }, { name: "rejects language too long", pathUserID: "user-123", authUserID: "user-123", body: PutPreferencesRequest{ Preferences: PreferencesPayload{ Theme: "dark", Language: "abcdefghijk", }, }, wantStatus: http.StatusBadRequest, }, { name: "rejects empty body", pathUserID: "user-123", authUserID: "user-123", body: nil, wantStatus: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := chi.NewRouter() r.Put("/api/preferences-api/preferences/{user_id}", withAuthUser(tt.authUserID, func(w http.ResponseWriter, r *http.Request) { if err := handler.Put(w, r); err != nil { writeErrorStatus(w, err) return } })) var body []byte if tt.body != nil { var err error body, err = json.Marshal(tt.body) if err != nil { t.Fatalf("failed to marshal body: %v", err) } } req := httptest.NewRequest(http.MethodPut, "/api/preferences-api/preferences/"+tt.pathUserID, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("expected status %d, got %d (body: %s)", tt.wantStatus, w.Code, w.Body.String()) } }) } } // writeErrorStatus writes an HTTP error status based on the error type. func writeErrorStatus(w http.ResponseWriter, err error) { w.WriteHeader(httperror.StatusCode(err)) }