package tests // Integration-style HTTP tests for question-bank routes, admin guard behavior, and metrics exposure. import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/prometheus/client_golang/prometheus" appq "knowfoolery/backend/services/question-bank-service/internal/application/question" domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" httpapi "knowfoolery/backend/services/question-bank-service/internal/interfaces/http" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/utils/validation" ) // inMemoryRepo is an in-memory repository used for HTTP integration tests. type inMemoryRepo struct { items map[string]*domain.Question } func newInMemoryRepo() *inMemoryRepo { return &inMemoryRepo{items: map[string]*domain.Question{}} } func (r *inMemoryRepo) GetByID(ctx context.Context, id string) (*domain.Question, error) { if q, ok := r.items[id]; ok { return q, nil } return nil, domain.ErrQuestionNotFound } func (r *inMemoryRepo) Create(ctx context.Context, q *domain.Question) (*domain.Question, error) { q.ID = "q-created" q.IsActive = true r.items[q.ID] = q return q, nil } func (r *inMemoryRepo) Update(ctx context.Context, id string, q *domain.Question) (*domain.Question, error) { if _, ok := r.items[id]; !ok { return nil, domain.ErrQuestionNotFound } q.ID = id r.items[id] = q return q, nil } func (r *inMemoryRepo) SoftDelete(ctx context.Context, id string) error { if q, ok := r.items[id]; ok { q.IsActive = false return nil } return domain.ErrQuestionNotFound } func (r *inMemoryRepo) ListThemes(ctx context.Context) ([]string, error) { return []string{"Science"}, nil } func (r *inMemoryRepo) CountRandomCandidates(ctx context.Context, filter domain.RandomFilter) (int, error) { count := 0 for _, q := range r.items { if !q.IsActive { continue } count++ } return count, nil } func (r *inMemoryRepo) RandomByOffset(ctx context.Context, filter domain.RandomFilter, offset int) (*domain.Question, error) { for _, q := range r.items { if q.IsActive { return q, nil } } return nil, domain.ErrNoQuestionsAvailable } func (r *inMemoryRepo) BulkCreate(ctx context.Context, questions []*domain.Question) (int, []domain.BulkError, error) { for i, q := range questions { id := "bulk-" + strconv.Itoa(i) q.ID = id q.IsActive = true r.items[id] = q } return len(questions), nil, nil } // noOpCache disables caching behavior in HTTP integration tests. type noOpCache struct{} func (c *noOpCache) Get(ctx context.Context, key string) (*domain.Question, bool) { return nil, false } func (c *noOpCache) Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration) {} func (c *noOpCache) Invalidate(ctx context.Context) {} // setupApp wires a test Fiber app with in-memory dependencies and admin middleware. func setupApp(t *testing.T) *fiber.App { t.Helper() repo := newInMemoryRepo() repo.items["q1"] = &domain.Question{ ID: "q1", Theme: "Science", Text: "What planet is largest?", Answer: "jupiter", Difficulty: domain.DifficultyMedium, IsActive: true, } svc := appq.NewService(repo, &noOpCache{}, time.Minute, 200) metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{ ServiceName: "question-bank-service-test", Enabled: true, Registry: prometheus.NewRegistry(), }) h := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics, 5000) app := fiber.New() adminMW := func(c fiber.Ctx) error { if c.Get("Authorization") == "Bearer admin" { return c.Next() } return c.SendStatus(http.StatusUnauthorized) } httpapi.RegisterRoutes(app, h, adminMW) app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) return app } // TestHTTPRandomAndGet validates public random and get-by-id endpoints. func TestHTTPRandomAndGet(t *testing.T) { app := setupApp(t) payload, _ := json.Marshal(map[string]any{}) req := httptest.NewRequest( http.MethodPost, "/questions/random", bytes.NewReader(payload), ) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("random failed: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/questions/q1", nil) resp, err = app.Test(req) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("get failed: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() } // TestHTTPAdminAuthAndMetrics validates admin auth guard and metrics endpoint availability. func TestHTTPAdminAuthAndMetrics(t *testing.T) { app := setupApp(t) createPayload, _ := json.Marshal(map[string]any{ "theme": "Science", "text": "What is H2O?", "answer": "water", "hint": "liquid", "difficulty": "easy", }) req := httptest.NewRequest( http.MethodPost, "/admin/questions", bytes.NewReader(createPayload), ) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil || resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected unauthorized: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() req = httptest.NewRequest( http.MethodPost, "/admin/questions", bytes.NewReader(createPayload), ) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer admin") resp, err = app.Test(req) if err != nil || resp.StatusCode != http.StatusCreated { t.Fatalf("expected created: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/metrics", nil) resp, err = app.Test(req) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("metrics failed: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() } // TestHTTPValidateAnswer covers matched/unmatched, missing question, and invalid answer responses. func TestHTTPValidateAnswer(t *testing.T) { app := setupApp(t) matchedPayload, _ := json.Marshal(map[string]any{"answer": "Jupiter"}) req := httptest.NewRequest( http.MethodPost, "/questions/q1/validate-answer", bytes.NewReader(matchedPayload), ) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("expected matched 200: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() unmatchedPayload, _ := json.Marshal(map[string]any{"answer": "Mars"}) req = httptest.NewRequest( http.MethodPost, "/questions/q1/validate-answer", bytes.NewReader(unmatchedPayload), ) req.Header.Set("Content-Type", "application/json") resp, err = app.Test(req) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("expected unmatched 200: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() req = httptest.NewRequest( http.MethodPost, "/questions/missing/validate-answer", bytes.NewReader(matchedPayload), ) req.Header.Set("Content-Type", "application/json") resp, err = app.Test(req) if err != nil || resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404 for missing question: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() badPayload, _ := json.Marshal(map[string]any{"answer": ""}) req = httptest.NewRequest( http.MethodPost, "/questions/q1/validate-answer", bytes.NewReader(badPayload), ) req.Header.Set("Content-Type", "application/json") resp, err = app.Test(req) if err != nil || resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 for empty answer: err=%v status=%d", err, resp.StatusCode) } defer resp.Body.Close() }