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.
239 lines
7.4 KiB
Go
239 lines
7.4 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"
|
|
sharedhttpx "knowfoolery/backend/shared/testutil/httpx"
|
|
)
|
|
|
|
// 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 := sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "random failed")
|
|
defer resp.Body.Close()
|
|
req = httptest.NewRequest(http.MethodGet, "/questions/q1", nil)
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "get failed")
|
|
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 := sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized")
|
|
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 = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusCreated, "expected created")
|
|
defer resp.Body.Close()
|
|
req = httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed")
|
|
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 := sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected matched 200")
|
|
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 = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected unmatched 200")
|
|
defer resp.Body.Close()
|
|
req = httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/questions/missing/validate-answer",
|
|
bytes.NewReader(matchedPayload),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNotFound, "expected 404 for missing question")
|
|
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 = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected 400 for empty answer")
|
|
defer resp.Body.Close()
|
|
}
|