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"]) } }