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.

256 lines
7.7 KiB
Go

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