package question // Tests for question application service behavior using fake repository and cache adapters. import ( "context" "errors" "testing" "time" domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" ) // fakeRepo is an in-memory repository double for service tests. type fakeRepo struct { items []*domain.Question } // GetByID returns a fake question by ID. func (f *fakeRepo) GetByID(ctx context.Context, id string) (*domain.Question, error) { for _, q := range f.items { if q.ID == id { return q, nil } } return nil, domain.ErrQuestionNotFound } // Create appends a fake question and assigns a deterministic ID. func (f *fakeRepo) Create(ctx context.Context, q *domain.Question) (*domain.Question, error) { q.ID = "id-1" f.items = append(f.items, q) return q, nil } // Update returns the provided fake question as updated. func (f *fakeRepo) Update(ctx context.Context, id string, q *domain.Question) (*domain.Question, error) { return q, nil } // SoftDelete is a no-op fake delete implementation. func (f *fakeRepo) SoftDelete(ctx context.Context, id string) error { return nil } // ListThemes returns a fixed fake theme set. func (f *fakeRepo) ListThemes(ctx context.Context) ([]string, error) { return []string{"Science"}, nil } // CountRandomCandidates returns fake candidate count from in-memory state. func (f *fakeRepo) CountRandomCandidates(ctx context.Context, filter domain.RandomFilter) (int, error) { if len(f.items) == 0 { return 0, nil } return len(f.items), nil } // RandomByOffset returns a fake candidate by offset. func (f *fakeRepo) RandomByOffset( ctx context.Context, filter domain.RandomFilter, offset int, ) (*domain.Question, error) { if len(f.items) == 0 { return nil, domain.ErrNoQuestionsAvailable } if offset >= len(f.items) { offset = 0 } return f.items[offset], nil } // BulkCreate appends all incoming questions and reports all as created. func (f *fakeRepo) BulkCreate( ctx context.Context, questions []*domain.Question, ) (int, []domain.BulkError, error) { f.items = append(f.items, questions...) return len(questions), nil, nil } // fakeCache is a simple in-memory cache double. type fakeCache struct { store map[string]*domain.Question } // Get returns a cached fake question when present. func (f *fakeCache) Get(ctx context.Context, key string) (*domain.Question, bool) { q, ok := f.store[key] return q, ok } // Set stores a fake question in cache. func (f *fakeCache) Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration) { if f.store == nil { f.store = map[string]*domain.Question{} } f.store[key] = q } // Invalidate is a no-op for tests. func (f *fakeCache) Invalidate(ctx context.Context) {} // TestGetRandomQuestion_NoQuestions returns ErrNoQuestionsAvailable when no active candidates exist. func TestGetRandomQuestion_NoQuestions(t *testing.T) { svc := NewService(&fakeRepo{}, &fakeCache{}, time.Minute, 200) _, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) if !errors.Is(err, domain.ErrNoQuestionsAvailable) { t.Fatalf("expected no questions error, got %v", err) } } // TestGetRandomQuestion_WithCache returns a random question successfully with cache enabled. func TestGetRandomQuestion_WithCache(t *testing.T) { repo := &fakeRepo{items: []*domain.Question{ { ID: "q1", Theme: "Science", Text: "Q", Answer: "A", Difficulty: domain.DifficultyMedium, IsActive: true, }, }} cache := &fakeCache{store: map[string]*domain.Question{}} svc := NewService(repo, cache, time.Minute, 200) q, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) if err != nil { t.Fatal(err) } if q.ID == "" { t.Fatal("expected id") } } // TestValidateAnswerByQuestionID returns a matched result and expected threshold for equivalent answers. func TestValidateAnswerByQuestionID(t *testing.T) { repo := &fakeRepo{items: []*domain.Question{ { ID: "q1", Theme: "Science", Text: "Q", Answer: "paris", Difficulty: domain.DifficultyEasy, IsActive: true, }, }} svc := NewService(repo, &fakeCache{}, time.Minute, 200) res, err := svc.ValidateAnswerByQuestionID(context.Background(), "q1", "Paris") if err != nil { t.Fatal(err) } if !res.Matched { t.Fatalf("expected match, got false score=%f", res.Score) } if res.Threshold != 0.85 { t.Fatalf("unexpected threshold %f", res.Threshold) } } // TestValidateAnswerByQuestionID_ValidationError returns validation error for empty provided answers. func TestValidateAnswerByQuestionID_ValidationError(t *testing.T) { repo := &fakeRepo{items: []*domain.Question{ { ID: "q1", Answer: "paris", Difficulty: domain.DifficultyEasy, IsActive: true, }, }} svc := NewService(repo, &fakeCache{}, time.Minute, 200) _, err := svc.ValidateAnswerByQuestionID(context.Background(), "q1", "") if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation error, got %v", err) } }