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.
401 lines
12 KiB
Go
401 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)
|
|
}
|
|
}
|