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.

340 lines
12 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
}
// newInMemoryRepo creates in-memory test doubles and deterministic fixtures.
func newInMemoryRepo() *inMemoryRepo {
return &inMemoryRepo{items: map[string]*domain.Question{}}
}
// GetByID retrieves data from the in-memory repository.
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
}
// Create persists a new entity in the in-memory repository.
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
}
// Update updates an existing entity in the in-memory repository.
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
}
// SoftDelete supports soft delete test setup and assertions.
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
}
// ListThemes returns filtered collections from the in-memory repository.
func (r *inMemoryRepo) ListThemes(ctx context.Context) ([]string, error) {
return []string{"Science"}, nil
}
// CountRandomCandidates returns aggregate counts from the in-memory repository.
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
}
// RandomByOffset supports random by offset test setup and assertions.
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
}
// BulkCreate supports bulk create test setup and assertions.
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{}
// Get retrieves data from the in-memory repository.
func (c *noOpCache) Get(ctx context.Context, key string) (*domain.Question, bool) {
return nil, false
}
// Set stores ephemeral state in the fake cache or state store.
func (c *noOpCache) Set(ctx context.Context, key string, q *domain.Question, ttl time.Duration) {}
// Invalidate removes ephemeral state from the fake cache or state store.
func (c *noOpCache) Invalidate(ctx context.Context) {}
// setupApp wires the test application with mocked dependencies.
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 ensures http random and get behavior is handled correctly.
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 ensures http admin auth and metrics behavior is handled correctly.
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 ensures http validate answer behavior is handled correctly.
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()
}
// TestHTTPAdminCRUDThemesAndBulk ensures http admin crud themes and bulk behavior is handled correctly.
func TestHTTPAdminCRUDThemesAndBulk(t *testing.T) {
app := setupApp(t)
updatePayload, _ := json.Marshal(map[string]any{
"theme": "Science",
"text": "Updated question text",
"answer": "jupiter",
"hint": "planet",
"difficulty": "medium",
"is_active": true,
})
req := httptest.NewRequest(http.MethodPut, "/admin/questions/q1", bytes.NewReader(updatePayload))
req.Header.Set("Content-Type", "application/json")
resp := sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized update")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodPut, "/admin/questions/q1", bytes.NewReader(updatePayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected update success")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodDelete, "/admin/questions/q1", nil)
req.Header.Set("Authorization", "Bearer admin")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNoContent, "expected delete success")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/admin/themes", nil)
req.Header.Set("Authorization", "Bearer admin")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected themes success")
defer resp.Body.Close()
emptyBulkPayload, _ := json.Marshal(map[string]any{"questions": []any{}})
req = httptest.NewRequest(http.MethodPost, "/admin/questions/bulk", bytes.NewReader(emptyBulkPayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bulk validation failure")
defer resp.Body.Close()
bulkPayload, _ := json.Marshal(map[string]any{
"questions": []map[string]any{
{
"theme": "History",
"text": "Who discovered America?",
"answer": "Columbus",
"hint": "1492",
"difficulty": "easy",
},
},
})
req = httptest.NewRequest(http.MethodPost, "/admin/questions/bulk", bytes.NewReader(bulkPayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer admin")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected bulk success")
defer resp.Body.Close()
}
// TestHTTPRandomQuestionInputValidation ensures http random question input validation behavior is handled correctly.
func TestHTTPRandomQuestionInputValidation(t *testing.T) {
app := setupApp(t)
req := httptest.NewRequest(http.MethodPost, "/questions/random", bytes.NewReader([]byte("{")))
req.Header.Set("Content-Type", "application/json")
resp := sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bad json failure")
defer resp.Body.Close()
payload, _ := json.Marshal(map[string]any{"difficulty": "legendary"})
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.StatusBadRequest, "expected invalid difficulty failure")
defer resp.Body.Close()
}