269 lines
7.3 KiB
Go
269 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|