You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

400 lines
12 KiB
Go

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)
}
}