package service import ( "context" "sync" "testing" "git.threesix.ai/jordan/slack5-1770529463/pkg/logging" "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/domain" "git.threesix.ai/jordan/slack5-1770529463/services/preferences-api/internal/port" ) // mockPreferenceRepository implements port.PreferenceRepository for testing. type mockPreferenceRepository struct { mu sync.RWMutex prefs map[string]map[string]string // userID -> key -> value } var _ port.PreferenceRepository = (*mockPreferenceRepository)(nil) func newMockPreferenceRepository() *mockPreferenceRepository { return &mockPreferenceRepository{ prefs: make(map[string]map[string]string), } } func (m *mockPreferenceRepository) GetByUserID(ctx context.Context, userID string) ([]port.PreferenceRow, error) { m.mu.RLock() defer m.mu.RUnlock() userPrefs, ok := m.prefs[userID] if !ok { return nil, nil } rows := make([]port.PreferenceRow, 0, len(userPrefs)) for k, v := range userPrefs { rows = append(rows, port.PreferenceRow{ UserID: userID, Key: k, Value: v, }) } return rows, nil } func (m *mockPreferenceRepository) Upsert(ctx context.Context, userID string, key string, value string) error { m.mu.Lock() defer m.mu.Unlock() if m.prefs[userID] == nil { m.prefs[userID] = make(map[string]string) } m.prefs[userID][key] = value return nil } func TestPreferenceService_GetPreferences(t *testing.T) { repo := newMockPreferenceRepository() svc := NewPreferenceService(repo, logging.Nop()) ctx := context.Background() validUserID := "550e8400-e29b-41d4-a716-446655440000" t.Run("returns defaults when no preferences stored", func(t *testing.T) { result, err := svc.GetPreferences(ctx, validUserID) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.UserID != validUserID { t.Errorf("expected user_id %s, got %s", validUserID, result.UserID) } if result.Preferences["theme"] != "system" { t.Errorf("expected default theme 'system', got %v", result.Preferences["theme"]) } if result.Preferences["language"] != "en" { t.Errorf("expected default language 'en', got %v", result.Preferences["language"]) } if result.Preferences["notifications_enabled"] != true { t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"]) } }) t.Run("merges stored values with defaults", func(t *testing.T) { // Store only theme _ = repo.Upsert(ctx, validUserID, "theme", "dark") result, err := svc.GetPreferences(ctx, validUserID) 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 default language 'en', got %v", result.Preferences["language"]) } if result.Preferences["notifications_enabled"] != true { t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"]) } }) t.Run("returns all stored values when all keys present", func(t *testing.T) { userID := "660e8400-e29b-41d4-a716-446655440000" _ = repo.Upsert(ctx, userID, "theme", "light") _ = repo.Upsert(ctx, userID, "language", "fr") _ = repo.Upsert(ctx, userID, "notifications_enabled", "false") result, err := svc.GetPreferences(ctx, userID) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Preferences["theme"] != "light" { t.Errorf("expected theme 'light', got %v", result.Preferences["theme"]) } if result.Preferences["language"] != "fr" { t.Errorf("expected language 'fr', got %v", result.Preferences["language"]) } if result.Preferences["notifications_enabled"] != false { t.Errorf("expected notifications_enabled false, got %v", result.Preferences["notifications_enabled"]) } }) t.Run("returns error for invalid user_id", func(t *testing.T) { _, err := svc.GetPreferences(ctx, "not-a-uuid") if err != domain.ErrInvalidUserID { t.Errorf("expected ErrInvalidUserID, got %v", err) } }) } func TestPreferenceService_UpdatePreferences(t *testing.T) { repo := newMockPreferenceRepository() svc := NewPreferenceService(repo, logging.Nop()) ctx := context.Background() validUserID := "550e8400-e29b-41d4-a716-446655440000" t.Run("updates preferences and returns full result", func(t *testing.T) { result, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ "theme": "dark", "language": "fr", }) 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"] != "fr" { t.Errorf("expected language 'fr', got %v", result.Preferences["language"]) } // notifications_enabled should be default if result.Preferences["notifications_enabled"] != true { t.Errorf("expected default notifications_enabled true, got %v", result.Preferences["notifications_enabled"]) } }) t.Run("handles boolean input for notifications_enabled", func(t *testing.T) { result, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ "notifications_enabled": false, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.Preferences["notifications_enabled"] != false { t.Errorf("expected notifications_enabled false, got %v", result.Preferences["notifications_enabled"]) } }) t.Run("rejects unknown key", func(t *testing.T) { _, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ "font_size": "large", }) if err == nil { t.Fatal("expected error for unknown key") } // Should wrap ErrUnknownPreferenceKey if !containsError(err, domain.ErrUnknownPreferenceKey) { t.Errorf("expected ErrUnknownPreferenceKey, got %v", err) } }) t.Run("rejects invalid theme value", func(t *testing.T) { _, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ "theme": "neon", }) if err == nil { t.Fatal("expected error for invalid theme") } if !containsError(err, domain.ErrInvalidPreferenceValue) { t.Errorf("expected ErrInvalidPreferenceValue, got %v", err) } }) t.Run("rejects invalid language value", func(t *testing.T) { _, err := svc.UpdatePreferences(ctx, validUserID, map[string]any{ "language": "123", }) if err == nil { t.Fatal("expected error for invalid language") } if !containsError(err, domain.ErrInvalidPreferenceValue) { t.Errorf("expected ErrInvalidPreferenceValue, got %v", err) } }) t.Run("rejects invalid user_id", func(t *testing.T) { _, err := svc.UpdatePreferences(ctx, "bad-id", map[string]any{ "theme": "dark", }) if err != domain.ErrInvalidUserID { t.Errorf("expected ErrInvalidUserID, got %v", err) } }) t.Run("is idempotent", func(t *testing.T) { userID := "770e8400-e29b-41d4-a716-446655440000" input := map[string]any{"theme": "dark"} result1, err := svc.UpdatePreferences(ctx, userID, input) if err != nil { t.Fatalf("unexpected error: %v", err) } result2, err := svc.UpdatePreferences(ctx, userID, input) if err != nil { t.Fatalf("unexpected error: %v", err) } if result1.Preferences["theme"] != result2.Preferences["theme"] { t.Errorf("expected idempotent result, got %v and %v", result1.Preferences["theme"], result2.Preferences["theme"]) } }) } // containsError checks if err wraps target using errors.Is-like behavior with fmt.Errorf wrapping. func containsError(err, target error) bool { if err == nil { return false } for e := err; e != nil; { if e == target { return true } if e.Error() == target.Error() { return true } u, ok := e.(interface{ Unwrap() error }) if !ok { break } e = u.Unwrap() } return false }