Improved backend test coverage

master
oabrivard 1 month ago
parent 7106e22d19
commit 6cadc15448

@ -76,7 +76,8 @@ Make wrappers:
- `make infra-up-prod`
### Backend Testing Strategy Implementation (6.1)
- Backend coverage gate: set by `BACKEND_COVERAGE_THRESHOLD` (default `80`), summary written to `reports/tests/backend-coverage-summary.txt`
- Backend coverage gate uses stepwise thresholds from `BACKEND_COVERAGE_THRESHOLDS` (default: `60 70 80`), summary written to `reports/tests/backend-coverage-summary.txt`
- Coverage gate aggregation targets backend service `internal/application`, `internal/domain`, and `internal/interfaces/http` packages from service profiles (`services-*`) with deduplicated unit/integration blocks
- Coverage profiles: generated in `reports/tests/coverage/*.out` and merged into `reports/tests/coverage/backend-combined.out`
- DB integration tests: question-bank repository integration now uses a disposable Postgres Testcontainers instance
- API route coverage: admin service integration tests now validate `/admin/auth`, `/admin/dashboard`, `/admin/audit` success and error paths

@ -0,0 +1,134 @@
package http
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/validation"
)
func TestUnauthorizedBranches(t *testing.T) {
h := NewHandler(nil, validation.NewValidator(), nil, nil)
app := fiber.New()
app.Post("/sessions/start", h.StartSession)
app.Post("/sessions/end", h.EndSession)
app.Post("/sessions/:id/hint", h.RequestHint)
app.Get("/sessions/:id", h.GetSession)
app.Get("/sessions/:id/question", h.GetCurrentQuestion)
cases := []struct {
method string
path string
}{
{http.MethodPost, "/sessions/start"},
{http.MethodPost, "/sessions/end"},
{http.MethodPost, "/sessions/s1/hint"},
{http.MethodGet, "/sessions/s1"},
{http.MethodGet, "/sessions/s1/question"},
}
for _, tc := range cases {
req := httptest.NewRequest(tc.method, tc.path, nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", tc.method, tc.path, err)
}
if res.StatusCode != http.StatusUnauthorized {
_ = res.Body.Close()
t.Fatalf("expected unauthorized for %s %s, got %d", tc.method, tc.path, res.StatusCode)
}
_ = res.Body.Close()
}
}
func TestStartAndEndValidationBranches(t *testing.T) {
h := NewHandler(nil, validation.NewValidator(), nil, nil)
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals("user_id", "user-1")
c.Locals("user_roles", []string{"player"})
return c.Next()
})
app.Post("/sessions/start", h.StartSession)
app.Post("/sessions/end", h.EndSession)
app.Post("/sessions/:id/answer", h.SubmitAnswer)
req := httptest.NewRequest(http.MethodPost, "/sessions/start", bytes.NewReader([]byte("{")))
req.Header.Set("Content-Type", "application/json")
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test start malformed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request for malformed start body, got %d", res.StatusCode)
}
req = httptest.NewRequest(http.MethodPost, "/sessions/start", bytes.NewReader([]byte(`{"preferred_theme":"a"}`)))
req.Header.Set("Content-Type", "application/json")
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test start validation: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request for start validation, got %d", res.StatusCode)
}
req = httptest.NewRequest(http.MethodPost, "/sessions/end", bytes.NewReader([]byte("{")))
req.Header.Set("Content-Type", "application/json")
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test end malformed: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request for malformed end body, got %d", res.StatusCode)
}
req = httptest.NewRequest(http.MethodPost, "/sessions/end", bytes.NewReader([]byte(`{"session_id":""}`)))
req.Header.Set("Content-Type", "application/json")
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test end validation: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request for end validation, got %d", res.StatusCode)
}
}
func TestBearerTokenAndClaimsHelpers(t *testing.T) {
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals("user_id", "admin-1")
c.Locals("user_roles", []string{"admin"})
return c.Next()
})
app.Get("/token", func(c fiber.Ctx) error {
if got := bearerToken(c); got != "abc" {
return c.SendStatus(http.StatusInternalServerError)
}
claims := authClaimsFromContext(c)
if !claims.IsAdmin || claims.UserID != "admin-1" {
return c.SendStatus(http.StatusInternalServerError)
}
return c.SendStatus(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/token", nil)
req.Header.Set("Authorization", "Bearer abc")
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test helper route: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected helper route success, got %d", res.StatusCode)
}
}

@ -3,6 +3,7 @@ module knowfoolery/backend/services/gateway-service
go 1.25.5
require (
github.com/alicebob/miniredis/v2 v2.33.0
github.com/gofiber/fiber/v3 v3.0.0-beta.3
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.7.0
@ -13,6 +14,7 @@ require (
require (
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@ -37,6 +39,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect

@ -0,0 +1,417 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/gofiber/fiber/v3"
redisv9 "github.com/redis/go-redis/v9"
gconfig "knowfoolery/backend/services/gateway-service/internal/infra/config"
"knowfoolery/backend/shared/infra/auth/zitadel"
"knowfoolery/backend/shared/infra/observability/logging"
)
func TestCORSMiddleware(t *testing.T) {
app := fiber.New()
app.Use(CORS(gconfig.CORSConfig{
AllowedOrigins: []string{"http://localhost:5173"},
AllowedMethods: "GET,POST,OPTIONS",
AllowedHeaders: "Content-Type,Authorization",
AllowCredentials: true,
MaxAgeSeconds: 120,
}))
app.Get("/ok", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.Header.Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("expected no CORS header without origin")
}
req = httptest.NewRequest(http.MethodOptions, "/ok", nil)
req.Header.Set("Origin", "http://evil.local")
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusForbidden {
t.Fatalf("expected forbidden preflight for disallowed origin, got %d", res.StatusCode)
}
req = httptest.NewRequest(http.MethodOptions, "/ok", nil)
req.Header.Set("Origin", "http://localhost:5173")
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
t.Fatalf("expected no content preflight, got %d", res.StatusCode)
}
if res.Header.Get("Access-Control-Allow-Origin") != "http://localhost:5173" {
t.Fatalf("unexpected allow origin: %s", res.Header.Get("Access-Control-Allow-Origin"))
}
if res.Header.Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("missing allow credentials")
}
}
func TestCORSAllowAll(t *testing.T) {
app := fiber.New()
app.Use(CORS(gconfig.CORSConfig{AllowedOrigins: []string{"*"}}))
app.Get("/ok", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
req.Header.Set("Origin", "http://anything.local")
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("expected wildcard origin header")
}
}
func TestSecurityHeadersMiddleware(t *testing.T) {
app := fiber.New()
app.Use(SecurityHeaders(gconfig.SecurityHeadersConfig{
ContentSecurityPolicy: "default-src 'self'",
EnableHSTS: true,
HSTSMaxAge: 31536000,
FrameOptions: "DENY",
ContentTypeOptions: true,
ReferrerPolicy: "strict-origin-when-cross-origin",
PermissionsPolicy: "geolocation=()",
}))
app.Get("/ok", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
req.Header.Set("X-Forwarded-Proto", "https")
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.Header.Get("Content-Security-Policy") == "" {
t.Fatalf("missing CSP header")
}
if res.Header.Get("X-Frame-Options") != "DENY" {
t.Fatalf("missing frame options")
}
if res.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("missing nosniff")
}
if res.Header.Get("Strict-Transport-Security") == "" {
t.Fatalf("expected HSTS header for https request")
}
}
func TestSecurityHeadersNoHSTSOnHTTP(t *testing.T) {
app := fiber.New()
app.Use(SecurityHeaders(gconfig.SecurityHeadersConfig{
EnableHSTS: true,
HSTSMaxAge: 31536000,
}))
app.Get("/ok", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.Header.Get("Strict-Transport-Security") != "" {
t.Fatalf("did not expect HSTS header on non-https request")
}
}
func TestRequestContextMiddlewareAndRequestID(t *testing.T) {
app := fiber.New()
app.Use(RequestContext(nil))
app.Get("/id", func(c fiber.Ctx) error {
if RequestID(c) == "" {
return c.SendStatus(http.StatusInternalServerError)
}
return c.SendStatus(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/id", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
if res.Header.Get("X-Request-ID") == "" {
t.Fatalf("expected generated request id")
}
req = httptest.NewRequest(http.MethodGet, "/id", nil)
req.Header.Set("X-Request-ID", "req-123")
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.Header.Get("X-Request-ID") != "req-123" {
t.Fatalf("expected propagated request id")
}
}
func TestRequestContextWithLoggerAndInvalidRequestIDLocal(t *testing.T) {
logger := logging.NewLogger(logging.DefaultConfig())
app := fiber.New()
app.Use(RequestContext(logger))
app.Get("/id", func(c fiber.Ctx) error {
c.Locals(requestIDKey, 123)
if RequestID(c) != "" {
return c.SendStatus(http.StatusInternalServerError)
}
return c.SendStatus(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/id", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
}
func TestRateLimitMiddlewareDegradedModeAndHelpers(t *testing.T) {
app := fiber.New()
mw := RateLimitMiddleware(nil, gconfig.RateLimitConfig{
GeneralRequests: 7,
APIRequests: 7,
}, "/api/v1", nil)
app.Use(mw)
app.Get("/api/v1/x", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", res.StatusCode)
}
if res.Header.Get("X-RateLimit-Policy") != "degraded" {
t.Fatalf("expected degraded policy")
}
if res.Header.Get("X-RateLimit-Limit") != "7" {
t.Fatalf("expected limit header")
}
app = fiber.New()
app.Use(mw)
app.Options("/api/v1/x", func(c fiber.Ctx) error { return c.SendStatus(http.StatusNoContent) })
req = httptest.NewRequest(http.MethodOptions, "/api/v1/x", nil)
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
t.Fatalf("expected options pass-through, got %d", res.StatusCode)
}
tiers := map[string]rateTier{
"general": {Name: "general"},
"auth": {Name: "auth"},
"api": {Name: "api"},
"admin": {Name: "admin"},
}
if got := selectTier("/api/v1/admin/auth", "/api/v1", tiers); got.Name != "auth" {
t.Fatalf("expected auth tier, got %s", got.Name)
}
if got := selectTier("/api/v1/admin/x", "/api/v1", tiers); got.Name != "admin" {
t.Fatalf("expected admin tier, got %s", got.Name)
}
if got := selectTier("/api/v1/x", "/api/v1", tiers); got.Name != "api" {
t.Fatalf("expected api tier, got %s", got.Name)
}
if got := selectTier("/x", "/api/v1", tiers); got.Name != "general" {
t.Fatalf("expected general tier, got %s", got.Name)
}
if v, ok := toInt64(int64(1)); !ok || v != 1 {
t.Fatalf("toInt64 int64 failed")
}
if v, ok := toInt64(int(2)); !ok || v != 2 {
t.Fatalf("toInt64 int failed")
}
if v, ok := toInt64("3"); !ok || v != 3 {
t.Fatalf("toInt64 string failed")
}
if _, ok := toInt64("bad"); ok {
t.Fatalf("expected invalid string parse failure")
}
if _, ok := toInt64(struct{}{}); ok {
t.Fatalf("expected unsupported type failure")
}
if allow, count, reset, ok := parseLimiterResult([]interface{}{int64(1), int64(2),
int64(3)}); !ok || !allow || count != 2 || reset != 3 {
t.Fatalf("unexpected parseLimiterResult output")
}
if _, _, _, ok := parseLimiterResult("bad"); ok {
t.Fatalf("expected parse failure for non-slice")
}
if _, _, _, ok := parseLimiterResult([]interface{}{1, 2}); ok {
t.Fatalf("expected parse failure for short slice")
}
if _, _, _, ok := parseLimiterResult([]interface{}{1, 2, struct{}{}}); ok {
t.Fatalf("expected parse failure for invalid reset")
}
if d := positiveDuration(0, time.Second); d != time.Second {
t.Fatalf("expected fallback duration")
}
if d := positiveDuration(2*time.Second, time.Second); d != 2*time.Second {
t.Fatalf("expected provided duration")
}
if m := maxInt(0, 9); m != 9 {
t.Fatalf("expected fallback int")
}
if m := maxInt(10, 9); m != 10 {
t.Fatalf("expected provided int")
}
}
func TestRateLimitMiddlewareRedisAllowedAndBlocked(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Skipf("miniredis unavailable in this environment: %v", err)
}
t.Cleanup(func() { mr.Close() })
client := redisv9.NewClient(&redisv9.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = client.Close() })
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals(string(zitadel.ContextKeyUserID), "user-1")
return c.Next()
})
app.Use(RateLimitMiddleware(
client,
gconfig.RateLimitConfig{
APIRequests: 1,
Window: time.Minute,
},
"/api/v1",
nil,
))
app.Get("/api/v1/x", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test first request: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected first request to pass, got %d", res.StatusCode)
}
if res.Header.Get("X-RateLimit-Limit") != "1" {
t.Fatalf("expected rate-limit headers")
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test second request: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request to be throttled, got %d", res.StatusCode)
}
if res.Header.Get("X-RateLimit-Remaining") != "0" {
t.Fatalf("expected remaining=0 when throttled")
}
}
func TestRateLimitMiddlewareRedisErrorDegrades(t *testing.T) {
// Use a client pointing to an unreachable endpoint to force script.Run error.
client := redisv9.NewClient(&redisv9.Options{Addr: "127.0.0.1:1"})
t.Cleanup(func() { _ = client.Close() })
app := fiber.New()
app.Use(RateLimitMiddleware(
client,
gconfig.RateLimitConfig{
APIRequests: 5,
Window: time.Minute,
},
"/api/v1",
nil,
))
app.Get("/api/v1/x", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected degraded mode to fail-open with 200, got %d", res.StatusCode)
}
if res.Header.Get("X-RateLimit-Policy") != "degraded" {
t.Fatalf("expected degraded policy header")
}
}
func TestIdentifyRequester(t *testing.T) {
app := fiber.New()
app.Get("/id", func(c fiber.Ctx) error {
if c.Query("u") == "1" {
c.Locals(string(zitadel.ContextKeyUserID), "user-1")
}
id := identifyRequester(c)
if c.Query("u") == "1" && id != "user:user-1" {
return c.Status(http.StatusInternalServerError).SendString(id)
}
if c.Query("u") != "1" && !strings.HasPrefix(id, "ip:") {
return c.Status(http.StatusInternalServerError).SendString(id)
}
return c.SendStatus(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/id?u=1", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for user requester")
}
req = httptest.NewRequest(http.MethodGet, "/id", nil)
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for ip requester")
}
}

@ -2,7 +2,9 @@ package leaderboard
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@ -13,6 +15,12 @@ import (
type fakeRepo struct {
entries []*domain.LeaderboardEntry
stats map[string]*domain.PlayerStats
ingestErr error
topErr error
statsErr error
rankErr error
historyErr error
globalErr error
}
func newFakeRepo() *fakeRepo {
@ -28,6 +36,9 @@ func (r *fakeRepo) IngestEntry(
ctx context.Context,
entry *domain.LeaderboardEntry,
) (*domain.LeaderboardEntry, bool, error) {
if r.ingestErr != nil {
return nil, false, r.ingestErr
}
for _, e := range r.entries {
if e.SessionID == entry.SessionID {
return e, true, nil
@ -82,6 +93,9 @@ func (r *fakeRepo) ListTop(
filter domain.TopFilter,
limit int,
) ([]*domain.LeaderboardEntry, error) {
if r.topErr != nil {
return nil, r.topErr
}
if len(r.entries) < limit {
limit = len(r.entries)
}
@ -93,6 +107,9 @@ func (r *fakeRepo) ListTop(
}
func (r *fakeRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) {
if r.statsErr != nil {
return nil, r.statsErr
}
stats := r.stats[playerID]
if stats == nil {
return nil, domain.ErrPlayerNotFound
@ -102,6 +119,9 @@ func (r *fakeRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain
}
func (r *fakeRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
if r.rankErr != nil {
return 0, r.rankErr
}
if _, ok := r.stats[playerID]; !ok {
return 0, domain.ErrPlayerNotFound
}
@ -113,6 +133,9 @@ func (r *fakeRepo) ListPlayerHistory(
playerID string,
pagination sharedtypes.Pagination,
) ([]*domain.LeaderboardEntry, int64, error) {
if r.historyErr != nil {
return nil, 0, r.historyErr
}
out := make([]*domain.LeaderboardEntry, 0)
for _, entry := range r.entries {
if entry.PlayerID == playerID {
@ -123,11 +146,16 @@ func (r *fakeRepo) ListPlayerHistory(
}
func (r *fakeRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) {
if r.globalErr != nil {
return nil, r.globalErr
}
return &domain.GlobalStats{TotalGames: int64(len(r.entries)), UpdatedAt: time.Now().UTC()}, nil
}
type fakeState struct {
data map[string]string
setErr error
deleteErr error
}
func newFakeState() *fakeState {
@ -138,10 +166,16 @@ func (s *fakeState) Get(ctx context.Context, key string) (string, bool) {
return v, ok
}
func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error {
if s.setErr != nil {
return s.setErr
}
s.data[key] = value
return nil
}
func (s *fakeState) Delete(ctx context.Context, keys ...string) error {
if s.deleteErr != nil {
return s.deleteErr
}
for _, key := range keys {
delete(s.data, key)
}
@ -229,3 +263,157 @@ func TestGetPlayerRanking(t *testing.T) {
t.Fatalf("history len=%d want=1", len(result.History))
}
}
func TestUpdateScoreValidationAndErrorPaths(t *testing.T) {
svc := NewService(newFakeRepo(), newFakeState(), Config{})
cases := []UpdateScoreInput{
{SessionID: "", PlayerID: "u1", CompletionType: "completed"},
{SessionID: "s1", PlayerID: "", CompletionType: "completed"},
{SessionID: "s1", PlayerID: "u1", TotalScore: -1, CompletionType: "completed"},
{SessionID: "s1", PlayerID: "u1", QuestionsAsked: 1, QuestionsCorrect: 2, CompletionType: "completed"},
{SessionID: "s1", PlayerID: "u1", CompletionType: "invalid"},
}
for i, in := range cases {
_, err := svc.UpdateScore(context.Background(), in)
if !errors.Is(err, domain.ErrInvalidInput) {
t.Fatalf("case %d: expected invalid input, got %v", i, err)
}
}
repo := newFakeRepo()
repo.ingestErr = errors.New("ingest boom")
svc = NewService(repo, newFakeState(), Config{})
_, err := svc.UpdateScore(context.Background(), UpdateScoreInput{
SessionID: "s1", PlayerID: "u1", PlayerName: "Alice", CompletionType: "completed",
})
if err == nil || !strings.Contains(err.Error(), "ingest boom") {
t.Fatalf("expected ingest error, got %v", err)
}
}
func TestTopAndStatsCachePaths(t *testing.T) {
repo := newFakeRepo()
state := newFakeState()
svc := NewService(repo, state, Config{})
_, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{
SessionID: "s1",
PlayerID: "u1",
PlayerName: "Alice",
TotalScore: 8,
QuestionsAsked: 10,
QuestionsCorrect: 7,
DurationSeconds: 100,
CompletedAt: time.Now().UTC(),
CompletionType: "completed",
})
top, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window7d})
if err != nil {
t.Fatalf("GetTop10 failed: %v", err)
}
if len(top.Items) == 0 || top.Items[0].Rank != 1 {
t.Fatalf("expected ranked top item")
}
cacheKey := "lb:top10:v1::7d"
payload, _ := json.Marshal(Top10Result{
Items: []RankedEntry{{Rank: 1, Entry: domain.LeaderboardEntry{SessionID: "cached"}}},
})
state.data[cacheKey] = string(payload)
cached, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window7d})
if err != nil {
t.Fatalf("GetTop10 cached failed: %v", err)
}
if cached.Items[0].Entry.SessionID != "cached" {
t.Fatalf("expected cached top payload")
}
stats, err := svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.WindowAll})
if err != nil {
t.Fatalf("GetGlobalStats failed: %v", err)
}
if stats.TotalGames <= 0 {
t.Fatalf("expected total games > 0")
}
state.data["lb:stats:global:v1::all"] = `{"TotalGames":42}`
cachedStats, err := svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.WindowAll})
if err != nil {
t.Fatalf("cached stats failed: %v", err)
}
if cachedStats.TotalGames != 42 {
t.Fatalf("expected cached total games")
}
}
func TestRankingValidationAndErrorPaths(t *testing.T) {
repo := newFakeRepo()
state := newFakeState()
svc := NewService(repo, state, Config{})
_, err := svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: ""})
if !errors.Is(err, domain.ErrInvalidInput) {
t.Fatalf("expected invalid input for empty player id, got %v", err)
}
_, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{
SessionID: "s1",
PlayerID: "u1",
PlayerName: "Alice",
TotalScore: 3,
QuestionsAsked: 4,
QuestionsCorrect: 2,
DurationSeconds: 50,
CompletedAt: time.Now().UTC(),
CompletionType: "completed",
})
state.data["lb:rank:u1"] = "not-a-number"
_, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{
PlayerID: "u1",
Pagination: sharedtypes.Pagination{
Page: 1,
PageSize: 1,
},
})
if err != nil {
t.Fatalf("expected fallback to repo rank when cache parse fails: %v", err)
}
repo.statsErr = errors.New("stats boom")
_, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"})
if err == nil || !strings.Contains(err.Error(), "stats boom") {
t.Fatalf("expected stats error, got %v", err)
}
repo.statsErr = nil
repo.rankErr = errors.New("rank boom")
state.data["lb:rank:u1"] = "0"
_, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"})
if err == nil || !strings.Contains(err.Error(), "rank boom") {
t.Fatalf("expected rank error, got %v", err)
}
repo.rankErr = nil
repo.historyErr = errors.New("history boom")
_, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"})
if err == nil || !strings.Contains(err.Error(), "history boom") {
t.Fatalf("expected history error, got %v", err)
}
}
func TestGlobalStatsAndTopErrors(t *testing.T) {
repo := newFakeRepo()
repo.topErr = errors.New("top boom")
svc := NewService(repo, newFakeState(), Config{})
_, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window("bogus")})
if err == nil || !strings.Contains(err.Error(), "top boom") {
t.Fatalf("expected top error, got %v", err)
}
repo = newFakeRepo()
repo.globalErr = errors.New("global boom")
svc = NewService(repo, newFakeState(), Config{})
_, err = svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.Window("bogus")})
if err == nil || !strings.Contains(err.Error(), "global boom") {
t.Fatalf("expected global error, got %v", err)
}
}

@ -0,0 +1,120 @@
package http
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/validation"
)
func TestUpdateAuthAndValidationBranches(t *testing.T) {
h := NewHandler(nil, validation.NewValidator(), nil, nil, true, 20, 100)
app := fiber.New()
app.Post("/leaderboard/update", h.Update)
req := httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader([]byte(`{}`)))
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusForbidden {
t.Fatalf("expected forbidden without auth claims, got %d", res.StatusCode)
}
app = fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals("user_id", "user-1")
c.Locals("user_roles", []string{"player"})
return c.Next()
})
app.Post("/leaderboard/update", h.Update)
req = httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader([]byte(`{}`)))
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusForbidden {
t.Fatalf("expected forbidden for non-service/non-admin, got %d", res.StatusCode)
}
app = fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals("user_id", "svc-1")
c.Locals("user_roles", []string{"service"})
return c.Next()
})
app.Post("/leaderboard/update", h.Update)
req = httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader([]byte("{")))
req.Header.Set("Content-Type", "application/json")
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request for malformed json, got %d", res.StatusCode)
}
validShape := `{
"session_id":"s1",
"player_id":"u1",
"player_name":"P",
"total_score":1,
"questions_asked":1,
"questions_correct":1,
"hints_used":0,
"duration_seconds":10,
"completed_at":"not-rfc3339",
"completion_type":"completed"
}`
req = httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader([]byte(validShape)))
req.Header.Set("Content-Type", "application/json")
res, err = app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected bad request for completed_at format, got %d", res.StatusCode)
}
}
func TestGetPlayerRankingForbiddenBranch(t *testing.T) {
h := NewHandler(nil, validation.NewValidator(), nil, nil, false, 20, 100)
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
c.Locals("user_id", "user-2")
c.Locals("user_roles", []string{"player"})
return c.Next()
})
app.Get("/leaderboard/players/:id", h.GetPlayerRanking)
req := httptest.NewRequest(http.MethodGet, "/leaderboard/players/user-1?page=oops&page_size=-1", nil)
res, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusForbidden {
t.Fatalf("expected forbidden for non-owner non-admin, got %d", res.StatusCode)
}
}
func TestHelperFunctions(t *testing.T) {
if got := atoiWithDefault("", 3); got != 3 {
t.Fatalf("expected default for empty input")
}
if got := atoiWithDefault("bad", 4); got != 4 {
t.Fatalf("expected default for invalid input")
}
if got := atoiWithDefault("7", 4); got != 7 {
t.Fatalf("expected parsed int")
}
}

@ -5,6 +5,7 @@ package question
import (
"context"
"errors"
"strings"
"testing"
"time"
@ -14,6 +15,15 @@ import (
// fakeRepo is an in-memory repository double for service tests.
type fakeRepo struct {
items []*domain.Question
createErr error
updateErr error
deleteErr error
listErr error
countErr error
randomErr error
bulkErr error
bulkErrors []domain.BulkError
bulkCount int
}
// GetByID returns a fake question by ID.
@ -28,6 +38,9 @@ func (f *fakeRepo) GetByID(ctx context.Context, id string) (*domain.Question, er
// Create appends a fake question and assigns a deterministic ID.
func (f *fakeRepo) Create(ctx context.Context, q *domain.Question) (*domain.Question, error) {
if f.createErr != nil {
return nil, f.createErr
}
q.ID = "id-1"
f.items = append(f.items, q)
return q, nil
@ -35,19 +48,30 @@ func (f *fakeRepo) Create(ctx context.Context, q *domain.Question) (*domain.Ques
// Update returns the provided fake question as updated.
func (f *fakeRepo) Update(ctx context.Context, id string, q *domain.Question) (*domain.Question, error) {
if f.updateErr != nil {
return nil, f.updateErr
}
return q, nil
}
// SoftDelete is a no-op fake delete implementation.
func (f *fakeRepo) SoftDelete(ctx context.Context, id string) error { return nil }
func (f *fakeRepo) SoftDelete(ctx context.Context, id string) error {
return f.deleteErr
}
// ListThemes returns a fixed fake theme set.
func (f *fakeRepo) ListThemes(ctx context.Context) ([]string, error) {
if f.listErr != nil {
return nil, f.listErr
}
return []string{"Science"}, nil
}
// CountRandomCandidates returns fake candidate count from in-memory state.
func (f *fakeRepo) CountRandomCandidates(ctx context.Context, filter domain.RandomFilter) (int, error) {
if f.countErr != nil {
return 0, f.countErr
}
if len(f.items) == 0 {
return 0, nil
}
@ -60,6 +84,9 @@ func (f *fakeRepo) RandomByOffset(
filter domain.RandomFilter,
offset int,
) (*domain.Question, error) {
if f.randomErr != nil {
return nil, f.randomErr
}
if len(f.items) == 0 {
return nil, domain.ErrNoQuestionsAvailable
}
@ -74,13 +101,20 @@ func (f *fakeRepo) BulkCreate(
ctx context.Context,
questions []*domain.Question,
) (int, []domain.BulkError, error) {
if f.bulkErr != nil {
return 0, nil, f.bulkErr
}
f.items = append(f.items, questions...)
return len(questions), nil, nil
if f.bulkCount > 0 {
return f.bulkCount, f.bulkErrors, nil
}
return len(questions), f.bulkErrors, nil
}
// fakeCache is a simple in-memory cache double.
type fakeCache struct {
store map[string]*domain.Question
invalidated bool
}
// Get returns a cached fake question when present.
@ -97,8 +131,8 @@ func (f *fakeCache) Set(ctx context.Context, key string, q *domain.Question, ttl
f.store[key] = q
}
// Invalidate is a no-op for tests.
func (f *fakeCache) Invalidate(ctx context.Context) {}
// Invalidate marks cache invalidation in tests.
func (f *fakeCache) Invalidate(ctx context.Context) { f.invalidated = true }
// TestGetRandomQuestion_NoQuestions returns ErrNoQuestionsAvailable when no active candidates exist.
func TestGetRandomQuestion_NoQuestions(t *testing.T) {
@ -131,6 +165,14 @@ func TestGetRandomQuestion_WithCache(t *testing.T) {
if q.ID == "" {
t.Fatal("expected id")
}
q2, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{})
if err != nil {
t.Fatal(err)
}
if q2.ID != q.ID {
t.Fatalf("expected cached question id %s, got %s", q.ID, q2.ID)
}
}
// TestValidateAnswerByQuestionID returns a matched result and expected threshold for equivalent answers.
@ -176,3 +218,177 @@ func TestValidateAnswerByQuestionID_ValidationError(t *testing.T) {
t.Fatalf("expected validation error, got %v", err)
}
}
func TestGetRandomQuestion_InvalidDifficulty(t *testing.T) {
svc := NewService(&fakeRepo{}, &fakeCache{}, time.Minute, 200)
_, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{
Difficulty: domain.Difficulty("legendary"),
})
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation error, got %v", err)
}
}
func TestGetRandomQuestion_RepoErrors(t *testing.T) {
repo := &fakeRepo{countErr: errors.New("count boom")}
svc := NewService(repo, &fakeCache{}, time.Minute, 200)
_, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{})
if err == nil || !strings.Contains(err.Error(), "count boom") {
t.Fatalf("expected count error, got %v", err)
}
repo = &fakeRepo{
items: []*domain.Question{{ID: "q1", Theme: "Science", Text: "Q", Answer: "A",
Difficulty: domain.DifficultyEasy, IsActive: true}},
randomErr: errors.New("random boom"),
}
svc = NewService(repo, &fakeCache{}, time.Minute, 200)
_, err = svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{})
if err == nil || !strings.Contains(err.Error(), "random boom") {
t.Fatalf("expected random error, got %v", err)
}
}
func TestQuestionCRUDAndThemes(t *testing.T) {
repo := &fakeRepo{}
cache := &fakeCache{}
svc := NewService(repo, cache, time.Minute, 200)
created, err := svc.CreateQuestion(context.Background(), CreateQuestionInput{
Theme: " Science ",
Text: "What is H2O?",
Answer: " Water ",
Hint: " liquid ",
Difficulty: domain.DifficultyEasy,
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
if created.ID == "" {
t.Fatalf("expected created id")
}
if !cache.invalidated {
t.Fatalf("expected cache invalidation after create")
}
cache.invalidated = false
_, err = svc.UpdateQuestion(context.Background(), "id-1", UpdateQuestionInput{
Theme: "Science",
Text: "Updated",
Answer: "Water",
Hint: "hint",
Difficulty: domain.DifficultyMedium,
IsActive: false,
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
if !cache.invalidated {
t.Fatalf("expected cache invalidation after update")
}
cache.invalidated = false
if err := svc.DeleteQuestion(context.Background(), "id-1"); err != nil {
t.Fatalf("delete failed: %v", err)
}
if !cache.invalidated {
t.Fatalf("expected cache invalidation after delete")
}
themes, err := svc.ListThemes(context.Background())
if err != nil {
t.Fatalf("list themes failed: %v", err)
}
if len(themes) != 1 || themes[0] != "Science" {
t.Fatalf("unexpected themes: %#v", themes)
}
}
func TestQuestionCRUDValidationAndErrors(t *testing.T) {
repo := &fakeRepo{createErr: errors.New("create boom")}
svc := NewService(repo, &fakeCache{}, time.Minute, 200)
_, err := svc.CreateQuestion(context.Background(), CreateQuestionInput{
Theme: "Science",
Text: "x",
Answer: "a",
Difficulty: domain.Difficulty("bad"),
})
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation error, got %v", err)
}
_, err = svc.CreateQuestion(context.Background(), CreateQuestionInput{
Theme: "Science",
Text: "x",
Answer: "a",
Difficulty: domain.DifficultyEasy,
})
if err == nil || !strings.Contains(err.Error(), "create boom") {
t.Fatalf("expected repo create error, got %v", err)
}
repo = &fakeRepo{updateErr: errors.New("update boom")}
svc = NewService(repo, &fakeCache{}, time.Minute, 200)
_, err = svc.UpdateQuestion(context.Background(), "id-1", UpdateQuestionInput{
Theme: "Science",
Text: "x",
Answer: "a",
Difficulty: domain.DifficultyEasy,
IsActive: true,
})
if err == nil || !strings.Contains(err.Error(), "update boom") {
t.Fatalf("expected repo update error, got %v", err)
}
repo = &fakeRepo{deleteErr: errors.New("delete boom"), listErr: errors.New("list boom")}
svc = NewService(repo, &fakeCache{}, time.Minute, 200)
err = svc.DeleteQuestion(context.Background(), "id-1")
if err == nil || !strings.Contains(err.Error(), "delete boom") {
t.Fatalf("expected repo delete error, got %v", err)
}
_, err = svc.ListThemes(context.Background())
if err == nil || !strings.Contains(err.Error(), "list boom") {
t.Fatalf("expected repo list error, got %v", err)
}
}
func TestBulkImportScenarios(t *testing.T) {
cache := &fakeCache{}
repo := &fakeRepo{bulkCount: 1, bulkErrors: []domain.BulkError{{Index: 0, Reason: "row"}}}
svc := NewService(repo, cache, time.Minute, 200)
_, err := svc.BulkImport(context.Background(), []BulkImportItem{
{Theme: "A"},
{Theme: "B"},
}, 1)
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected max-items validation error, got %v", err)
}
result, err := svc.BulkImport(context.Background(), []BulkImportItem{
{Theme: "Science", Text: "What is H2O?", Answer: "water", Hint: "liquid", Difficulty: domain.DifficultyEasy},
{Theme: "", Text: "bad", Answer: "bad", Hint: "bad", Difficulty: domain.DifficultyEasy},
}, 10)
if err != nil {
t.Fatalf("bulk import failed: %v", err)
}
if result.CreatedCount != 1 {
t.Fatalf("expected one created, got %d", result.CreatedCount)
}
if len(result.Errors) == 0 {
t.Fatalf("expected error list for invalid rows")
}
if !cache.invalidated {
t.Fatalf("expected cache invalidation after bulk import")
}
repo = &fakeRepo{bulkErr: errors.New("bulk boom")}
svc = NewService(repo, &fakeCache{}, time.Minute, 200)
_, err = svc.BulkImport(context.Background(), []BulkImportItem{
{Theme: "Science", Text: "What is H2O?", Answer: "water", Hint: "liquid", Difficulty: domain.DifficultyEasy},
}, 10)
if err == nil || !strings.Contains(err.Error(), "bulk boom") {
t.Fatalf("expected repo bulk error, got %v", err)
}
}

@ -236,3 +236,85 @@ func TestHTTPValidateAnswer(t *testing.T) {
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected 400 for empty answer")
defer resp.Body.Close()
}
// TestHTTPAdminCRUDThemesAndBulk validates admin-only CRUD, themes, and bulk import flows.
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 validates malformed and invalid random question inputs.
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()
}

@ -2,6 +2,8 @@ package user
import (
"context"
"errors"
"strings"
"testing"
"time"
@ -16,6 +18,16 @@ type fakeRepo struct {
usersByEmail map[string]*domain.User
usersByZitadel map[string]*domain.User
audit []domain.AuditLogEntry
createErr error
getByIDErr error
getByEmailErr error
getByZidErr error
updateErr error
verifyErr error
deleteErr error
listErr error
logsErr error
writeAuditErr error
}
// newFakeRepo creates a fake repository with empty stores.
@ -32,6 +44,9 @@ func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil }
// Create stores a user and assigns deterministic timestamps and ID for tests.
func (r *fakeRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
if r.createErr != nil {
return nil, r.createErr
}
user.ID = "u-1"
now := time.Now().UTC()
user.CreatedAt = now
@ -44,6 +59,9 @@ func (r *fakeRepo) Create(ctx context.Context, user *domain.User) (*domain.User,
// GetByID returns a non-deleted user by ID.
func (r *fakeRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
if r.getByIDErr != nil {
return nil, r.getByIDErr
}
if u, ok := r.usersByID[id]; ok && !u.IsDeleted() {
return u, nil
}
@ -52,6 +70,9 @@ func (r *fakeRepo) GetByID(ctx context.Context, id string) (*domain.User, error)
// GetByEmail returns a non-deleted user by email.
func (r *fakeRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
if r.getByEmailErr != nil {
return nil, r.getByEmailErr
}
if u, ok := r.usersByEmail[email]; ok && !u.IsDeleted() {
return u, nil
}
@ -60,6 +81,9 @@ func (r *fakeRepo) GetByEmail(ctx context.Context, email string) (*domain.User,
// GetByZitadelUserID returns a non-deleted user by Zitadel subject.
func (r *fakeRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) {
if r.getByZidErr != nil {
return nil, r.getByZidErr
}
if u, ok := r.usersByZitadel[zid]; ok && !u.IsDeleted() {
return u, nil
}
@ -72,6 +96,9 @@ func (r *fakeRepo) UpdateProfile(
id, displayName string,
consent domain.ConsentRecord,
) (*domain.User, error) {
if r.updateErr != nil {
return nil, r.updateErr
}
u, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
@ -86,6 +113,9 @@ func (r *fakeRepo) UpdateProfile(
// MarkEmailVerified marks a user as verified in the fake store.
func (r *fakeRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) {
if r.verifyErr != nil {
return nil, r.verifyErr
}
u, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
@ -97,6 +127,9 @@ func (r *fakeRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.Us
// SoftDelete marks a user as deleted and records an audit entry.
func (r *fakeRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error {
if r.deleteErr != nil {
return r.deleteErr
}
u, err := r.GetByID(ctx, id)
if err != nil {
return err
@ -120,6 +153,9 @@ func (r *fakeRepo) List(
pagination sharedtypes.Pagination,
filter domain.ListFilter,
) ([]*domain.User, int64, error) {
if r.listErr != nil {
return nil, 0, r.listErr
}
items := make([]*domain.User, 0, len(r.usersByID))
for _, u := range r.usersByID {
if !filter.IncludeDeleted && u.IsDeleted() {
@ -132,6 +168,9 @@ func (r *fakeRepo) List(
// AuditLogsByUserID returns audit entries associated with a target user.
func (r *fakeRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) {
if r.logsErr != nil {
return nil, r.logsErr
}
out := make([]domain.AuditLogEntry, 0)
for _, a := range r.audit {
if a.TargetUserID == id {
@ -143,6 +182,9 @@ func (r *fakeRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.A
// WriteAuditLog appends an audit entry to the fake repository.
func (r *fakeRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error {
if r.writeAuditErr != nil {
return r.writeAuditErr
}
r.audit = append(r.audit, entry)
return nil
}
@ -204,3 +246,237 @@ func TestDeleteAndExport(t *testing.T) {
t.Fatal("expected deleted user to be unavailable")
}
}
func TestRegisterValidationAndRepoErrors(t *testing.T) {
svc := NewService(newFakeRepo())
_, err := svc.Register(context.Background(), RegisterInput{})
if !errors.Is(err, domain.ErrUnauthorized) {
t.Fatalf("expected unauthorized, got %v", err)
}
_, err = svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zid-1",
ActorEmail: "bad-email",
DisplayName: "Player",
ConsentVersion: "v1",
ConsentSource: "web",
})
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation for bad email, got %v", err)
}
_, err = svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zid-1",
ActorEmail: "a@b.com",
DisplayName: "",
ConsentVersion: "v1",
ConsentSource: "web",
})
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation for display name, got %v", err)
}
repo := newFakeRepo()
repo.getByZidErr = errors.New("zid boom")
svc = NewService(repo)
_, err = svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zid-1",
ActorEmail: "p@example.com",
DisplayName: "Player",
ConsentVersion: "v1",
ConsentSource: "web",
})
if err == nil || !strings.Contains(err.Error(), "zid boom") {
t.Fatalf("expected zid lookup error, got %v", err)
}
repo = newFakeRepo()
repo.createErr = errors.New("create boom")
svc = NewService(repo)
_, err = svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zid-1",
ActorEmail: "p@example.com",
DisplayName: "Player",
ConsentVersion: "v1",
ConsentSource: "web",
})
if err == nil || !strings.Contains(err.Error(), "create boom") {
t.Fatalf("expected create error, got %v", err)
}
}
func TestProfileAndEmailFlows(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
user, err := svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zid-1",
ActorEmail: "player@example.com",
DisplayName: "Player One",
ConsentVersion: "v1",
ConsentSource: "web",
})
if err != nil {
t.Fatalf("register failed: %v", err)
}
_, err = svc.GetProfile(context.Background(), "")
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation on empty id, got %v", err)
}
updated, err := svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{
DisplayName: "Player Renamed",
ConsentVersion: "v2",
ConsentSource: "",
})
if err != nil {
t.Fatalf("update profile failed: %v", err)
}
if updated.DisplayName != "Player Renamed" {
t.Fatalf("unexpected updated name: %s", updated.DisplayName)
}
verified, err := svc.VerifyEmail(context.Background(), user.ID)
if err != nil {
t.Fatalf("verify email failed: %v", err)
}
if !verified.EmailVerified {
t.Fatalf("expected email verified")
}
}
func TestProfileValidationAndRepoErrors(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
user, _ := svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zid-1",
ActorEmail: "player@example.com",
DisplayName: "Player One",
ConsentVersion: "v1",
ConsentSource: "web",
})
_, err := svc.UpdateProfile(context.Background(), "", UpdateProfileInput{DisplayName: "x", ConsentVersion: "v1"})
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation on empty user id, got %v", err)
}
_, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{DisplayName: "", ConsentVersion: "v1"})
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation on display name, got %v", err)
}
_, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{DisplayName: "ok",
ConsentVersion: strings.Repeat("a", 40)})
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation on consent version, got %v", err)
}
_, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{
DisplayName: "ok",
ConsentVersion: "v1",
ConsentSource: strings.Repeat("a", 40),
})
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected validation on consent source, got %v", err)
}
repo.updateErr = errors.New("update boom")
_, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{
DisplayName: "ok",
ConsentVersion: "v1",
ConsentSource: "web",
})
if err == nil || !strings.Contains(err.Error(), "update boom") {
t.Fatalf("expected update repo error, got %v", err)
}
repo.verifyErr = errors.New("verify boom")
_, err = svc.VerifyEmail(context.Background(), user.ID)
if err == nil || !strings.Contains(err.Error(), "verify boom") {
t.Fatalf("expected verify repo error, got %v", err)
}
}
func TestAdminListAndExport(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
user, _ := svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zid-1",
ActorEmail: "player@example.com",
DisplayName: "Player One",
ConsentVersion: "v1",
ConsentSource: "web",
})
repo.audit = append(repo.audit, domain.AuditLogEntry{TargetUserID: user.ID, Action: domain.AuditActionGDPRExport})
items, total, pagination, err := svc.AdminListUsers(context.Background(), ListUsersInput{
Page: 0, PageSize: 0, Email: "PLAYER@EXAMPLE.COM", DisplayName: " Player ",
})
if err != nil {
t.Fatalf("admin list failed: %v", err)
}
if total == 0 || len(items) == 0 {
t.Fatalf("expected listed users")
}
if pagination.Page <= 0 || pagination.PageSize <= 0 {
t.Fatalf("expected normalized pagination, got %+v", pagination)
}
bundle, err := svc.AdminExportUser(context.Background(), user.ID, "admin-1")
if err != nil {
t.Fatalf("admin export failed: %v", err)
}
if bundle.User.ID != user.ID {
t.Fatalf("unexpected export user id: %s", bundle.User.ID)
}
if len(repo.audit) == 0 {
t.Fatalf("expected audit writes during export")
}
}
func TestDeleteListExportErrors(t *testing.T) {
repo := newFakeRepo()
svc := NewService(repo)
err := svc.DeleteUser(context.Background(), "", "admin")
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected delete validation error, got %v", err)
}
repo.deleteErr = errors.New("delete boom")
err = svc.DeleteUser(context.Background(), "u-1", "admin")
if err == nil || !strings.Contains(err.Error(), "delete boom") {
t.Fatalf("expected delete repo error, got %v", err)
}
repo.listErr = errors.New("list boom")
_, _, _, err = svc.AdminListUsers(context.Background(), ListUsersInput{Page: 1, PageSize: 10})
if err == nil || !strings.Contains(err.Error(), "list boom") {
t.Fatalf("expected list repo error, got %v", err)
}
_, err = svc.AdminExportUser(context.Background(), "", "admin")
if !errors.Is(err, domain.ErrValidationFailed) {
t.Fatalf("expected export validation error, got %v", err)
}
repo = newFakeRepo()
svc = NewService(repo)
repo.getByIDErr = errors.New("profile boom")
_, err = svc.AdminExportUser(context.Background(), "u-1", "admin")
if err == nil || !strings.Contains(err.Error(), "profile boom") {
t.Fatalf("expected get profile error, got %v", err)
}
repo = newFakeRepo()
svc = NewService(repo)
user, _ := svc.Register(context.Background(), RegisterInput{
ActorZitadelUserID: "zid-1",
ActorEmail: "player@example.com",
DisplayName: "Player One",
ConsentVersion: "v1",
ConsentSource: "web",
})
repo.logsErr = errors.New("logs boom")
_, err = svc.AdminExportUser(context.Background(), user.ID, "admin")
if err == nil || !strings.Contains(err.Error(), "logs boom") {
t.Fatalf("expected logs error, got %v", err)
}
}

@ -301,3 +301,62 @@ func TestMetricsEndpoint(t *testing.T) {
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed")
defer resp.Body.Close()
}
// TestVerifyEmailAndValidationPaths covers verify-email success/unauthorized and admin query validation errors.
func TestVerifyEmailAndValidationPaths(t *testing.T) {
app, _ := setupApp(t)
req := httptest.NewRequest(http.MethodPost, "/users/verify-email", nil)
resp := sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized verify-email")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodPost, "/users/verify-email", nil)
req.Header.Set("Authorization", "Bearer user-1")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected verify-email success")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/admin/users?created_after=not-rfc3339", nil)
req.Header.Set("Authorization", "Bearer admin")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected created_after validation failure")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodGet, "/admin/users?created_before=not-rfc3339", nil)
req.Header.Set("Authorization", "Bearer admin")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected created_before validation failure")
defer resp.Body.Close()
}
// TestRegisterAndUpdateInvalidPayloads covers invalid request-body and validation paths.
func TestRegisterAndUpdateInvalidPayloads(t *testing.T) {
app, _ := setupApp(t)
req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader([]byte("{")))
req.Header.Set("Authorization", "Bearer user-1")
req.Header.Set("Content-Type", "application/json")
resp := sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bad json for register")
defer resp.Body.Close()
invalidRegister, _ := json.Marshal(map[string]any{
"display_name": "",
"consent_version": "v1",
"consent_source": "web",
})
req = httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(invalidRegister))
req.Header.Set("Authorization", "Bearer user-1")
req.Header.Set("Content-Type", "application/json")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected validation error for register")
defer resp.Body.Close()
req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader([]byte("{")))
req.Header.Set("Authorization", "Bearer user-1")
req.Header.Set("Content-Type", "application/json")
resp = sharedhttpx.MustTest(t, app, req)
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bad json for update")
defer resp.Body.Close()
}

@ -104,7 +104,7 @@ tasks:
'
enforce-backend-coverage:
desc: Aggregate backend coverage and fail if below threshold
desc: Aggregate backend service coverage and enforce stepwise thresholds up to 80%
cmds:
- |
bash -o pipefail -c '
@ -113,27 +113,72 @@ tasks:
COVERAGE_DIR="reports/tests/coverage"
COMBINED="${COVERAGE_DIR}/backend-combined.out"
SUMMARY="reports/tests/backend-coverage-summary.txt"
THRESHOLD="${BACKEND_COVERAGE_THRESHOLD:-80}"
THRESHOLDS="${BACKEND_COVERAGE_THRESHOLDS:-60 70 80}"
rm -f "${COMBINED}"
echo "mode: atomic" > "${COMBINED}"
found=0
merged_input=""
for profile in "${COVERAGE_DIR}"/*.out; do
[ -f "${profile}" ] || continue
tail -n +2 "${profile}" >> "${COMBINED}"
found=1
[ "${profile}" = "${COMBINED}" ] && continue
case "${profile}" in
*shared-unit.out) continue ;;
esac
merged_input="${merged_input} ${profile}"
done
if [ "${found}" -eq 0 ]; then
if [ -z "${merged_input}" ]; then
echo "No backend coverage profiles were generated." | tee "${SUMMARY}"
exit 1
fi
# Deduplicate statement blocks across unit/integration profiles by keeping max count per block.
# shellcheck disable=SC2086
tail -n +2 ${merged_input} | sort -k1,1 | awk "
{
if (NR == 1) {
prev = \$1
stmt = \$2
max = \$3
next
}
if (\$1 != prev) {
print prev, stmt, max
prev = \$1
stmt = \$2
max = \$3
next
}
if ((\$3 + 0) > (max + 0)) {
max = \$3
}
}
END {
if (NR > 0) {
print prev, stmt, max
}
}
" >> "${COMBINED}"
total_stats="$(awk "
NR > 1 {
split(\$1, loc, \":\")
path = loc[1]
pkg = path
sub(/\\/[^\\/]*$/, \"\", pkg)
# Focus on service application/domain and HTTP interface logic.
if (pkg !~ /^knowfoolery\\/backend\\/services\\//) {
next
}
if (pkg !~ /\\/internal\\/(application|domain)\\// &&
pkg !~ /\\/internal\\/interfaces\\/http($|\\/)/) {
next
}
total += \$2
if (\$3 > 0) {
if ((\$3 + 0) > 0) {
covered += \$2
}
}
@ -150,12 +195,14 @@ tasks:
total_stmt="$(echo "${total_stats}" | awk "{print \$3}")"
{
echo "Backend total coverage: ${total_pct}%"
echo "Backend service (application/domain/interfaces-http) coverage: ${total_pct}%"
echo "Covered statements: ${covered_stmt}/${total_stmt}"
echo "Coverage threshold: ${THRESHOLD}%"
echo "Threshold progression: ${THRESHOLDS}%"
} | tee "${SUMMARY}"
awk -v coverage="${total_pct}" -v threshold="${THRESHOLD}" "BEGIN { exit((coverage+0) < (threshold+0)) }"
for threshold in ${THRESHOLDS}; do
awk -v coverage="${total_pct}" -v threshold="${threshold}" "BEGIN { exit((coverage+0) < (threshold+0)) }"
done
'
docker-build-validate:

Loading…
Cancel
Save