slack5-1770544098/services/preferences-api/internal/api/handlers/preferences_test.go
rdev-worker a31f57382b
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
build: /implement-feature user-preferences
2026-02-08 10:47:23 +00:00

286 lines
7.7 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/slack5-1770544098/pkg/app"
"git.threesix.ai/jordan/slack5-1770544098/pkg/auth"
"git.threesix.ai/jordan/slack5-1770544098/pkg/logging"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/domain"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/port"
"git.threesix.ai/jordan/slack5-1770544098/services/preferences-api/internal/service"
)
const testUserID = "550e8400-e29b-41d4-a716-446655440000"
const otherUserID = "550e8400-e29b-41d4-a716-446655440001"
// mockPrefsRepository implements port.PreferencesRepository for handler testing.
type mockPrefsRepository struct {
prefs map[string]*domain.UserPreferences
}
var _ port.PreferencesRepository = (*mockPrefsRepository)(nil)
func newMockPrefsRepository() *mockPrefsRepository {
return &mockPrefsRepository{
prefs: make(map[string]*domain.UserPreferences),
}
}
func (m *mockPrefsRepository) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) {
p, ok := m.prefs[userID]
if !ok {
return nil, nil
}
cp := *p
return &cp, nil
}
func (m *mockPrefsRepository) Upsert(ctx context.Context, userID string, prefs map[string]any) (*domain.UserPreferences, error) {
existing, ok := m.prefs[userID]
if !ok {
existing = &domain.UserPreferences{
UserID: userID,
Preferences: map[string]any{},
}
}
merged := make(map[string]any)
for k, v := range existing.Preferences {
merged[k] = v
}
for k, v := range prefs {
merged[k] = v
}
result := &domain.UserPreferences{
UserID: userID,
Preferences: merged,
}
m.prefs[userID] = result
return result, nil
}
func newPrefsTestHandler() (*Preferences, *mockPrefsRepository) {
repo := newMockPrefsRepository()
svc := service.NewPreferencesService(repo, logging.Nop())
handler := NewPreferences(svc, logging.Nop())
return handler, repo
}
// withAuthUser adds an authenticated user to the request context.
func withAuthUser(r *http.Request, userID string) *http.Request {
ctx := auth.SetUser(r.Context(), &auth.User{ID: userID})
return r.WithContext(ctx)
}
func TestPreferences_Get(t *testing.T) {
tests := []struct {
name string
userID string
authUserID string
seedPrefs map[string]any
wantStatus int
wantData bool
}{
{
name: "returns 200 with preferences for existing user",
userID: testUserID,
authUserID: testUserID,
seedPrefs: map[string]any{"theme": "dark", "language": "en"},
wantStatus: http.StatusOK,
wantData: true,
},
{
name: "returns 200 with empty preferences for new user",
userID: testUserID,
authUserID: testUserID,
seedPrefs: nil,
wantStatus: http.StatusOK,
wantData: true,
},
{
name: "returns 400 for invalid UUID",
userID: "not-a-uuid",
authUserID: testUserID,
wantStatus: http.StatusBadRequest,
},
{
name: "returns 403 for ownership mismatch",
userID: testUserID,
authUserID: otherUserID,
wantStatus: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler, repo := newPrefsTestHandler()
if tt.seedPrefs != nil {
repo.prefs[tt.userID] = &domain.UserPreferences{
UserID: tt.userID,
Preferences: tt.seedPrefs,
}
}
r := chi.NewRouter()
r.Get("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Get))
req := httptest.NewRequest(http.MethodGet, "/api/preferences-api/preferences/"+tt.userID, nil)
req = withAuthUser(req, tt.authUserID)
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())
}
if tt.wantData {
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' object in response")
}
if _, ok := resp["meta"]; !ok {
t.Fatal("expected 'meta' field in response")
}
if data["user_id"] != tt.userID {
t.Errorf("expected user_id %s, got %v", tt.userID, data["user_id"])
}
prefs, ok := data["preferences"].(map[string]any)
if !ok {
t.Fatal("expected 'preferences' map in data")
}
if tt.seedPrefs == nil && len(prefs) != 0 {
t.Errorf("expected empty preferences, got %v", prefs)
}
if tt.seedPrefs != nil {
for k, v := range tt.seedPrefs {
if prefs[k] != v {
t.Errorf("expected preferences[%s] = %v, got %v", k, v, prefs[k])
}
}
}
}
})
}
}
func TestPreferences_Update(t *testing.T) {
tests := []struct {
name string
userID string
authUserID string
body any
wantStatus int
wantData bool
}{
{
name: "returns 200 with merged preferences on success",
userID: testUserID,
authUserID: testUserID,
body: map[string]any{"preferences": map[string]any{"theme": "dark"}},
wantStatus: http.StatusOK,
wantData: true,
},
{
name: "returns 400 for unknown preference keys",
userID: testUserID,
authUserID: testUserID,
body: map[string]any{"preferences": map[string]any{"unknown": "value"}},
wantStatus: http.StatusBadRequest,
},
{
name: "returns 400 for invalid preference values",
userID: testUserID,
authUserID: testUserID,
body: map[string]any{"preferences": map[string]any{"theme": "blue"}},
wantStatus: http.StatusBadRequest,
},
{
name: "returns 400 for missing preferences field",
userID: testUserID,
authUserID: testUserID,
body: map[string]any{},
wantStatus: http.StatusBadRequest,
},
{
name: "returns 400 for invalid UUID",
userID: "not-a-uuid",
authUserID: testUserID,
body: map[string]any{"preferences": map[string]any{"theme": "dark"}},
wantStatus: http.StatusBadRequest,
},
{
name: "returns 403 for ownership mismatch",
userID: testUserID,
authUserID: otherUserID,
body: map[string]any{"preferences": map[string]any{"theme": "dark"}},
wantStatus: http.StatusForbidden,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler, _ := newPrefsTestHandler()
r := chi.NewRouter()
r.Put("/api/preferences-api/preferences/{user_id}", app.Wrap(handler.Update))
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.userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = withAuthUser(req, tt.authUserID)
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())
}
if tt.wantData {
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' object in response")
}
if _, ok := resp["meta"]; !ok {
t.Fatal("expected 'meta' field in response")
}
if data["user_id"] != tt.userID {
t.Errorf("expected user_id %s, got %v", tt.userID, data["user_id"])
}
prefs, ok := data["preferences"].(map[string]any)
if !ok {
t.Fatal("expected 'preferences' map in data")
}
if prefs["theme"] != "dark" {
t.Errorf("expected theme 'dark', got %v", prefs["theme"])
}
}
})
}
}