slack5-1770529463/services/preferences-api/internal/service/preference_test.go
rdev-worker 73532902e7
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
build: /implement-feature user-preferences
2026-02-08 06:13:10 +00:00

254 lines
7.7 KiB
Go

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
}