package service import ( "context" "sync" "testing" "git.threesix.ai/jordan/slack5-1770603014/pkg/logging" "git.threesix.ai/jordan/slack5-1770603014/services/preferences-api/internal/domain" "git.threesix.ai/jordan/slack5-1770603014/services/preferences-api/internal/port" ) // mockPreferencesRepository implements port.PreferencesRepository for testing. 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() p, ok := m.store[userID] if !ok { return nil, nil } cp := *p return &cp, nil } func (m *mockPreferencesRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error { m.mu.Lock() defer m.mu.Unlock() cp := *prefs m.store[prefs.UserID] = &cp return nil } func strPtr(s string) *string { return &s } func boolPtr(b bool) *bool { return &b } func TestPreferencesService_GetPreferences(t *testing.T) { repo := newMockPreferencesRepository() svc := NewPreferencesService(repo, logging.Nop()) t.Run("returns defaults for unknown user", func(t *testing.T) { prefs, err := svc.GetPreferences(context.Background(), "unknown-user") if err != nil { t.Fatalf("unexpected error: %v", err) } if prefs.Preferences.Theme != "system" { t.Errorf("expected theme 'system', got '%s'", prefs.Preferences.Theme) } if prefs.Preferences.Language != "en" { t.Errorf("expected language 'en', got '%s'", prefs.Preferences.Language) } if prefs.Preferences.Notifications.Email != true { t.Error("expected notifications.email true") } if prefs.Preferences.Notifications.Push != true { t.Error("expected notifications.push true") } if prefs.Preferences.Notifications.Digest != "weekly" { t.Errorf("expected notifications.digest 'weekly', got '%s'", prefs.Preferences.Notifications.Digest) } }) t.Run("returns stored preferences for existing user", func(t *testing.T) { // Seed data repo.mu.Lock() repo.store["user-1"] = &domain.UserPreferences{ UserID: "user-1", Preferences: domain.Preferences{ Theme: "dark", Language: "fr", Notifications: domain.NotificationSettings{ Email: false, Push: true, Digest: "daily", }, }, } repo.mu.Unlock() prefs, err := svc.GetPreferences(context.Background(), "user-1") if err != nil { t.Fatalf("unexpected error: %v", err) } if prefs.Preferences.Theme != "dark" { t.Errorf("expected theme 'dark', got '%s'", prefs.Preferences.Theme) } if prefs.Preferences.Language != "fr" { t.Errorf("expected language 'fr', got '%s'", prefs.Preferences.Language) } }) } func TestPreferencesService_UpdatePreferences(t *testing.T) { repo := newMockPreferencesRepository() svc := NewPreferencesService(repo, logging.Nop()) t.Run("creates new preferences from defaults", func(t *testing.T) { prefs, err := svc.UpdatePreferences(context.Background(), "new-user", UpdateInput{ Theme: strPtr("dark"), }) if err != nil { t.Fatalf("unexpected error: %v", err) } if prefs.Preferences.Theme != "dark" { t.Errorf("expected theme 'dark', got '%s'", prefs.Preferences.Theme) } // Other fields should be defaults if prefs.Preferences.Language != "en" { t.Errorf("expected language 'en', got '%s'", prefs.Preferences.Language) } if prefs.Preferences.Notifications.Digest != "weekly" { t.Errorf("expected digest 'weekly', got '%s'", prefs.Preferences.Notifications.Digest) } }) t.Run("merges partial data with existing preferences", func(t *testing.T) { // First set full preferences _, _ = svc.UpdatePreferences(context.Background(), "merge-user", UpdateInput{ Theme: strPtr("light"), Language: strPtr("es"), Notifications: &NotificationsInput{ Email: boolPtr(false), Push: boolPtr(false), Digest: strPtr("daily"), }, }) // Now partial update - only change push notification prefs, err := svc.UpdatePreferences(context.Background(), "merge-user", UpdateInput{ Notifications: &NotificationsInput{ Push: boolPtr(true), }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } // Push should be updated if prefs.Preferences.Notifications.Push != true { t.Error("expected notifications.push true") } // Other fields should be unchanged if prefs.Preferences.Theme != "light" { t.Errorf("expected theme 'light', got '%s'", prefs.Preferences.Theme) } if prefs.Preferences.Language != "es" { t.Errorf("expected language 'es', got '%s'", prefs.Preferences.Language) } if prefs.Preferences.Notifications.Email != false { t.Error("expected notifications.email false") } if prefs.Preferences.Notifications.Digest != "daily" { t.Errorf("expected digest 'daily', got '%s'", prefs.Preferences.Notifications.Digest) } }) t.Run("updates theme only", func(t *testing.T) { prefs, err := svc.UpdatePreferences(context.Background(), "theme-user", UpdateInput{ Theme: strPtr("light"), }) if err != nil { t.Fatalf("unexpected error: %v", err) } if prefs.Preferences.Theme != "light" { t.Errorf("expected theme 'light', got '%s'", prefs.Preferences.Theme) } }) t.Run("updates language only", func(t *testing.T) { prefs, err := svc.UpdatePreferences(context.Background(), "lang-user", UpdateInput{ Language: strPtr("fr"), }) if err != nil { t.Fatalf("unexpected error: %v", err) } if prefs.Preferences.Language != "fr" { t.Errorf("expected language 'fr', got '%s'", prefs.Preferences.Language) } }) t.Run("updates all fields together", func(t *testing.T) { prefs, err := svc.UpdatePreferences(context.Background(), "all-user", UpdateInput{ Theme: strPtr("dark"), Language: strPtr("de"), Notifications: &NotificationsInput{ Email: boolPtr(false), Push: boolPtr(true), Digest: strPtr("never"), }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if prefs.Preferences.Theme != "dark" { t.Errorf("expected theme 'dark', got '%s'", prefs.Preferences.Theme) } if prefs.Preferences.Language != "de" { t.Errorf("expected language 'de', got '%s'", prefs.Preferences.Language) } if prefs.Preferences.Notifications.Email != false { t.Error("expected notifications.email false") } if prefs.Preferences.Notifications.Push != true { t.Error("expected notifications.push true") } if prefs.Preferences.Notifications.Digest != "never" { t.Errorf("expected digest 'never', got '%s'", prefs.Preferences.Notifications.Digest) } }) t.Run("rejects invalid theme", func(t *testing.T) { _, err := svc.UpdatePreferences(context.Background(), "invalid-theme-user", UpdateInput{ Theme: strPtr("purple"), }) if err != domain.ErrInvalidTheme { t.Errorf("expected ErrInvalidTheme, got %v", err) } }) t.Run("rejects invalid digest", func(t *testing.T) { _, err := svc.UpdatePreferences(context.Background(), "invalid-digest-user", UpdateInput{ Notifications: &NotificationsInput{ Digest: strPtr("monthly"), }, }) if err != domain.ErrInvalidDigest { t.Errorf("expected ErrInvalidDigest, got %v", err) } }) t.Run("rejects empty language", func(t *testing.T) { _, err := svc.UpdatePreferences(context.Background(), "empty-lang-user", UpdateInput{ Language: strPtr(""), }) if err != domain.ErrInvalidLanguage { t.Errorf("expected ErrInvalidLanguage, got %v", err) } }) }