package service import ( "context" "errors" "sync" "testing" "git.threesix.ai/jordan/slack5-1770606136/pkg/logging" "git.threesix.ai/jordan/slack5-1770606136/services/preferences-api/internal/domain" "git.threesix.ai/jordan/slack5-1770606136/services/preferences-api/internal/port" ) // mockPreferenceRepository implements port.PreferenceRepository for testing. type mockPreferenceRepository struct { mu sync.RWMutex prefs map[string]*domain.UserPreferences } var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil) func newMockPreferenceRepository() *mockPreferenceRepository { return &mockPreferenceRepository{ prefs: make(map[string]*domain.UserPreferences), } } func (m *mockPreferenceRepository) Get(ctx context.Context, userID string) (*domain.UserPreferences, error) { m.mu.RLock() defer m.mu.RUnlock() p, ok := m.prefs[userID] if !ok { return nil, nil } // Return a copy cp := *p cpPrefs := make(map[string]any) for k, v := range p.Preferences { cpPrefs[k] = v } cp.Preferences = cpPrefs return &cp, nil } func (m *mockPreferenceRepository) Upsert(ctx context.Context, prefs *domain.UserPreferences) error { m.mu.Lock() defer m.mu.Unlock() cp := *prefs cpPrefs := make(map[string]any) for k, v := range prefs.Preferences { cpPrefs[k] = v } cp.Preferences = cpPrefs m.prefs[prefs.UserID] = &cp return nil } func TestPreferenceService_Get(t *testing.T) { repo := newMockPreferenceRepository() svc := NewPreferenceService(repo, logging.Nop()) t.Run("returns nil for non-existent user", func(t *testing.T) { result, err := svc.Get(context.Background(), "user-1") if err != nil { t.Fatalf("unexpected error: %v", err) } if result != nil { t.Error("expected nil result for non-existent user") } }) t.Run("returns existing preferences", func(t *testing.T) { _, _ = svc.Upsert(context.Background(), UpsertInput{ UserID: "user-2", Preferences: map[string]any{"theme": "dark"}, }) result, err := svc.Get(context.Background(), "user-2") if err != nil { t.Fatalf("unexpected error: %v", err) } if result == nil { t.Fatal("expected non-nil result") } if result.Preferences["theme"] != "dark" { t.Errorf("expected theme 'dark', got '%v'", result.Preferences["theme"]) } }) } func TestPreferenceService_Upsert(t *testing.T) { repo := newMockPreferenceRepository() svc := NewPreferenceService(repo, logging.Nop()) t.Run("creates preferences for new user", func(t *testing.T) { result, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-1", Preferences: map[string]any{ "theme": "dark", "language": "en", }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Preferences["theme"] != "dark" { t.Errorf("expected theme 'dark', got '%v'", result.Preferences["theme"]) } if result.Preferences["language"] != "en" { t.Errorf("expected language 'en', got '%v'", result.Preferences["language"]) } }) t.Run("merges preferences with existing", func(t *testing.T) { result, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-1", Preferences: map[string]any{ "notifications_enabled": true, }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } // Existing keys should be preserved if result.Preferences["theme"] != "dark" { t.Errorf("expected theme 'dark' preserved, got '%v'", result.Preferences["theme"]) } if result.Preferences["language"] != "en" { t.Errorf("expected language 'en' preserved, got '%v'", result.Preferences["language"]) } // New key should be added if result.Preferences["notifications_enabled"] != true { t.Errorf("expected notifications_enabled true, got '%v'", result.Preferences["notifications_enabled"]) } }) t.Run("overwrites existing key", func(t *testing.T) { result, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-1", Preferences: map[string]any{ "theme": "light", }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Preferences["theme"] != "light" { t.Errorf("expected theme 'light', got '%v'", result.Preferences["theme"]) } // Other keys preserved if result.Preferences["language"] != "en" { t.Errorf("expected language 'en' preserved, got '%v'", result.Preferences["language"]) } }) t.Run("accepts unknown keys without validation", func(t *testing.T) { result, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-2", Preferences: map[string]any{ "custom_setting": "anything", "sidebar_width": 42.0, }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Preferences["custom_setting"] != "anything" { t.Errorf("expected custom_setting 'anything', got '%v'", result.Preferences["custom_setting"]) } }) t.Run("rejects invalid theme", func(t *testing.T) { _, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-3", Preferences: map[string]any{ "theme": "neon", }, }) if err == nil { t.Fatal("expected error for invalid theme") } if !errors.Is(err, domain.ErrInvalidPreferenceValue) { t.Errorf("expected ErrInvalidPreferenceValue, got %v", err) } var valErr *ValidationError if !errors.As(err, &valErr) { t.Fatal("expected ValidationError type") } if valErr.Details["theme"] == "" { t.Error("expected details for theme key") } }) t.Run("rejects invalid language", func(t *testing.T) { _, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-3", Preferences: map[string]any{ "language": "not-a-language-!!!", }, }) if err == nil { t.Fatal("expected error for invalid language") } var valErr *ValidationError if !errors.As(err, &valErr) { t.Fatal("expected ValidationError type") } if valErr.Details["language"] == "" { t.Error("expected details for language key") } }) t.Run("rejects non-boolean notifications_enabled", func(t *testing.T) { _, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-3", Preferences: map[string]any{ "notifications_enabled": "yes", }, }) if err == nil { t.Fatal("expected error for non-boolean notifications_enabled") } var valErr *ValidationError if !errors.As(err, &valErr) { t.Fatal("expected ValidationError type") } if valErr.Details["notifications_enabled"] == "" { t.Error("expected details for notifications_enabled key") } }) t.Run("collects multiple validation errors", func(t *testing.T) { _, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-3", Preferences: map[string]any{ "theme": "invalid", "notifications_enabled": "not-bool", }, }) if err == nil { t.Fatal("expected error for multiple invalid values") } var valErr *ValidationError if !errors.As(err, &valErr) { t.Fatal("expected ValidationError type") } if len(valErr.Details) != 2 { t.Errorf("expected 2 validation errors, got %d", len(valErr.Details)) } }) t.Run("accepts valid BCP-47 tags", func(t *testing.T) { validTags := []string{"en", "fr", "es", "de", "ja", "zh-Hans", "pt-BR"} for _, tag := range validTags { _, err := svc.Upsert(context.Background(), UpsertInput{ UserID: "user-lang-" + tag, Preferences: map[string]any{ "language": tag, }, }) if err != nil { t.Errorf("expected no error for valid BCP-47 tag '%s', got %v", tag, err) } } }) }