package question // Tests for question application service behavior using fake repository and cache adapters. import ( "context" "errors" "strings" "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 createErr error updateErr error deleteErr error listErr error countErr error randomErr error bulkErr error bulkErrors []domain.BulkError bulkCount int } // GetByID retrieves data from the in-memory repository. 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 persists a new entity in the in-memory repository. func (f *fakeRepo) Create(ctx context.Context, q *domain.Question) (*domain.Question, error) { if f.createErr != nil { return nil, f.createErr } q.ID = "id-1" f.items = append(f.items, q) return q, nil } // Update updates an existing entity in the in-memory repository. func (f *fakeRepo) Update(ctx context.Context, id string, q *domain.Question) (*domain.Question, error) { if f.updateErr != nil { return nil, f.updateErr } return q, nil } // SoftDelete supports soft delete test setup and assertions. func (f *fakeRepo) SoftDelete(ctx context.Context, id string) error { return f.deleteErr } // ListThemes returns filtered collections from the in-memory repository. func (f *fakeRepo) ListThemes(ctx context.Context) ([]string, error) { if f.listErr != nil { return nil, f.listErr } return []string{"Science"}, nil } // CountRandomCandidates returns aggregate counts from the in-memory repository. func (f *fakeRepo) CountRandomCandidates(ctx context.Context, filter domain.RandomFilter) (int, error) { if f.countErr != nil { return 0, f.countErr } if len(f.items) == 0 { return 0, nil } return len(f.items), nil } // RandomByOffset supports random by offset test setup and assertions. func (f *fakeRepo) RandomByOffset( ctx context.Context, filter domain.RandomFilter, offset int, ) (*domain.Question, error) { if f.randomErr != nil { return nil, f.randomErr } if len(f.items) == 0 { return nil, domain.ErrNoQuestionsAvailable } if offset >= len(f.items) { offset = 0 } return f.items[offset], nil } // BulkCreate supports bulk create test setup and assertions. func (f *fakeRepo) BulkCreate( ctx context.Context, questions []*domain.Question, ) (int, []domain.BulkError, error) { if f.bulkErr != nil { return 0, nil, f.bulkErr } f.items = append(f.items, questions...) if f.bulkCount > 0 { return f.bulkCount, f.bulkErrors, nil } return len(questions), f.bulkErrors, nil } // fakeCache is a simple in-memory cache double. type fakeCache struct { store map[string]*domain.Question invalidated bool } // Get retrieves data from the in-memory repository. func (f *fakeCache) Get(ctx context.Context, key string) (*domain.Question, bool) { q, ok := f.store[key] return q, ok } // Set stores ephemeral state in the fake cache or state store. 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 removes ephemeral state from the fake cache or state store. func (f *fakeCache) Invalidate(ctx context.Context) { f.invalidated = true } // TestGetRandomQuestion_NoQuestions ensures get random question no questions behavior is handled correctly. 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 ensures get random question with cache behavior is handled correctly. 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") } q2, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) if err != nil { t.Fatal(err) } if q2.ID != q.ID { t.Fatalf("expected cached question id %s, got %s", q.ID, q2.ID) } } // TestValidateAnswerByQuestionID ensures validate answer by question id behavior is handled correctly. 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 ensures validate answer by question id validation error behavior is handled correctly. 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) } } // TestGetRandomQuestion_InvalidDifficulty ensures get random question invalid difficulty behavior is handled correctly. func TestGetRandomQuestion_InvalidDifficulty(t *testing.T) { svc := NewService(&fakeRepo{}, &fakeCache{}, time.Minute, 200) _, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{ Difficulty: domain.Difficulty("legendary"), }) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation error, got %v", err) } } // TestGetRandomQuestion_RepoErrors ensures get random question repo errors behavior is handled correctly. func TestGetRandomQuestion_RepoErrors(t *testing.T) { repo := &fakeRepo{countErr: errors.New("count boom")} svc := NewService(repo, &fakeCache{}, time.Minute, 200) _, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) if err == nil || !strings.Contains(err.Error(), "count boom") { t.Fatalf("expected count error, got %v", err) } repo = &fakeRepo{ items: []*domain.Question{{ID: "q1", Theme: "Science", Text: "Q", Answer: "A", Difficulty: domain.DifficultyEasy, IsActive: true}}, randomErr: errors.New("random boom"), } svc = NewService(repo, &fakeCache{}, time.Minute, 200) _, err = svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) if err == nil || !strings.Contains(err.Error(), "random boom") { t.Fatalf("expected random error, got %v", err) } } // TestQuestionCRUDAndThemes ensures question crud and themes behavior is handled correctly. func TestQuestionCRUDAndThemes(t *testing.T) { repo := &fakeRepo{} cache := &fakeCache{} svc := NewService(repo, cache, time.Minute, 200) created, err := svc.CreateQuestion(context.Background(), CreateQuestionInput{ Theme: " Science ", Text: "What is H2O?", Answer: " Water ", Hint: " liquid ", Difficulty: domain.DifficultyEasy, }) if err != nil { t.Fatalf("create failed: %v", err) } if created.ID == "" { t.Fatalf("expected created id") } if !cache.invalidated { t.Fatalf("expected cache invalidation after create") } cache.invalidated = false _, err = svc.UpdateQuestion(context.Background(), "id-1", UpdateQuestionInput{ Theme: "Science", Text: "Updated", Answer: "Water", Hint: "hint", Difficulty: domain.DifficultyMedium, IsActive: false, }) if err != nil { t.Fatalf("update failed: %v", err) } if !cache.invalidated { t.Fatalf("expected cache invalidation after update") } cache.invalidated = false if err := svc.DeleteQuestion(context.Background(), "id-1"); err != nil { t.Fatalf("delete failed: %v", err) } if !cache.invalidated { t.Fatalf("expected cache invalidation after delete") } themes, err := svc.ListThemes(context.Background()) if err != nil { t.Fatalf("list themes failed: %v", err) } if len(themes) != 1 || themes[0] != "Science" { t.Fatalf("unexpected themes: %#v", themes) } } // TestQuestionCRUDValidationAndErrors ensures question crud validation and errors behavior is handled correctly. func TestQuestionCRUDValidationAndErrors(t *testing.T) { repo := &fakeRepo{createErr: errors.New("create boom")} svc := NewService(repo, &fakeCache{}, time.Minute, 200) _, err := svc.CreateQuestion(context.Background(), CreateQuestionInput{ Theme: "Science", Text: "x", Answer: "a", Difficulty: domain.Difficulty("bad"), }) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected validation error, got %v", err) } _, err = svc.CreateQuestion(context.Background(), CreateQuestionInput{ Theme: "Science", Text: "x", Answer: "a", Difficulty: domain.DifficultyEasy, }) if err == nil || !strings.Contains(err.Error(), "create boom") { t.Fatalf("expected repo create error, got %v", err) } repo = &fakeRepo{updateErr: errors.New("update boom")} svc = NewService(repo, &fakeCache{}, time.Minute, 200) _, err = svc.UpdateQuestion(context.Background(), "id-1", UpdateQuestionInput{ Theme: "Science", Text: "x", Answer: "a", Difficulty: domain.DifficultyEasy, IsActive: true, }) if err == nil || !strings.Contains(err.Error(), "update boom") { t.Fatalf("expected repo update error, got %v", err) } repo = &fakeRepo{deleteErr: errors.New("delete boom"), listErr: errors.New("list boom")} svc = NewService(repo, &fakeCache{}, time.Minute, 200) err = svc.DeleteQuestion(context.Background(), "id-1") if err == nil || !strings.Contains(err.Error(), "delete boom") { t.Fatalf("expected repo delete error, got %v", err) } _, err = svc.ListThemes(context.Background()) if err == nil || !strings.Contains(err.Error(), "list boom") { t.Fatalf("expected repo list error, got %v", err) } } // TestBulkImportScenarios ensures bulk import behavior is handled correctly. func TestBulkImportScenarios(t *testing.T) { cache := &fakeCache{} repo := &fakeRepo{bulkCount: 1, bulkErrors: []domain.BulkError{{Index: 0, Reason: "row"}}} svc := NewService(repo, cache, time.Minute, 200) _, err := svc.BulkImport(context.Background(), []BulkImportItem{ {Theme: "A"}, {Theme: "B"}, }, 1) if !errors.Is(err, domain.ErrValidationFailed) { t.Fatalf("expected max-items validation error, got %v", err) } result, err := svc.BulkImport(context.Background(), []BulkImportItem{ {Theme: "Science", Text: "What is H2O?", Answer: "water", Hint: "liquid", Difficulty: domain.DifficultyEasy}, {Theme: "", Text: "bad", Answer: "bad", Hint: "bad", Difficulty: domain.DifficultyEasy}, }, 10) if err != nil { t.Fatalf("bulk import failed: %v", err) } if result.CreatedCount != 1 { t.Fatalf("expected one created, got %d", result.CreatedCount) } if len(result.Errors) == 0 { t.Fatalf("expected error list for invalid rows") } if !cache.invalidated { t.Fatalf("expected cache invalidation after bulk import") } repo = &fakeRepo{bulkErr: errors.New("bulk boom")} svc = NewService(repo, &fakeCache{}, time.Minute, 200) _, err = svc.BulkImport(context.Background(), []BulkImportItem{ {Theme: "Science", Text: "What is H2O?", Answer: "water", Hint: "liquid", Difficulty: domain.DifficultyEasy}, }, 10) if err == nil || !strings.Contains(err.Error(), "bulk boom") { t.Fatalf("expected repo bulk error, got %v", err) } }