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