From 995c4524086932f5dbd21440c06ec6622b57c1cf Mon Sep 17 00:00:00 2001 From: oabrivard Date: Mon, 2 Feb 2026 22:45:05 +0100 Subject: [PATCH] Added unit tests for steps up to 1.3.2 --- backend/go.work.sum | 2 + backend/services/admin-service/go.mod | 1 + backend/services/admin-service/go.sum | 2 +- backend/services/game-session-service/go.mod | 1 + backend/services/game-session-service/go.sum | 2 +- backend/services/gateway-service/go.mod | 1 + backend/services/gateway-service/go.sum | 2 +- backend/services/leaderboard-service/go.mod | 1 + backend/services/leaderboard-service/go.sum | 2 +- backend/services/question-bank-service/go.mod | 1 + backend/services/question-bank-service/go.sum | 2 +- backend/services/user-service/go.mod | 1 + backend/services/user-service/go.sum | 2 +- backend/shared/domain/errors/errors_test.go | 40 ++++++ backend/shared/domain/events/event_test.go | 28 ++++ backend/shared/domain/types/id_test.go | 23 +++ .../shared/domain/types/pagination_test.go | 32 +++++ .../domain/valueobjects/player_name_test.go | 55 +++++++ .../shared/domain/valueobjects/score_test.go | 51 +++++++ backend/shared/go.mod | 5 + backend/shared/go.sum | 10 +- backend/shared/infra/auth/rbac/roles_test.go | 43 ++++++ .../shared/infra/auth/zitadel/client_test.go | 60 ++++++++ .../shared/infra/auth/zitadel/middleware.go | 7 +- .../infra/auth/zitadel/middleware_test.go | 136 ++++++++++++++++++ .../infra/database/postgres/client_test.go | 31 ++++ .../infra/database/redis/client_test.go | 46 ++++++ .../observability/logging/logger_test.go | 32 +++++ .../infra/observability/metrics/prometheus.go | 39 ++--- .../observability/metrics/prometheus_test.go | 21 +++ .../observability/tracing/tracer_test.go | 37 +++++ .../shared/infra/security/sanitize_test.go | 69 +++++++++ .../infra/utils/httputil/errors_test.go | 64 +++++++++ .../infra/utils/httputil/pagination_test.go | 74 ++++++++++ .../infra/utils/httputil/response_test.go | 85 +++++++++++ .../infra/utils/validation/validator_test.go | 61 ++++++++ 36 files changed, 1044 insertions(+), 25 deletions(-) create mode 100644 backend/shared/domain/errors/errors_test.go create mode 100644 backend/shared/domain/events/event_test.go create mode 100644 backend/shared/domain/types/id_test.go create mode 100644 backend/shared/domain/types/pagination_test.go create mode 100644 backend/shared/domain/valueobjects/player_name_test.go create mode 100644 backend/shared/domain/valueobjects/score_test.go create mode 100644 backend/shared/infra/auth/rbac/roles_test.go create mode 100644 backend/shared/infra/auth/zitadel/client_test.go create mode 100644 backend/shared/infra/auth/zitadel/middleware_test.go create mode 100644 backend/shared/infra/database/postgres/client_test.go create mode 100644 backend/shared/infra/database/redis/client_test.go create mode 100644 backend/shared/infra/observability/logging/logger_test.go create mode 100644 backend/shared/infra/observability/metrics/prometheus_test.go create mode 100644 backend/shared/infra/observability/tracing/tracer_test.go create mode 100644 backend/shared/infra/security/sanitize_test.go create mode 100644 backend/shared/infra/utils/httputil/errors_test.go create mode 100644 backend/shared/infra/utils/httputil/pagination_test.go create mode 100644 backend/shared/infra/utils/httputil/response_test.go create mode 100644 backend/shared/infra/utils/validation/validator_test.go diff --git a/backend/go.work.sum b/backend/go.work.sum index c310269..2662ebc 100644 --- a/backend/go.work.sum +++ b/backend/go.work.sum @@ -13,6 +13,7 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= @@ -22,5 +23,6 @@ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/backend/services/admin-service/go.mod b/backend/services/admin-service/go.mod index 364be46..c04c702 100644 --- a/backend/services/admin-service/go.mod +++ b/backend/services/admin-service/go.mod @@ -11,6 +11,7 @@ require ( github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/backend/services/admin-service/go.sum b/backend/services/admin-service/go.sum index ce79cef..d6f473f 100644 --- a/backend/services/admin-service/go.sum +++ b/backend/services/admin-service/go.sum @@ -8,7 +8,7 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= diff --git a/backend/services/game-session-service/go.mod b/backend/services/game-session-service/go.mod index 42fda53..dfac2ba 100644 --- a/backend/services/game-session-service/go.mod +++ b/backend/services/game-session-service/go.mod @@ -11,6 +11,7 @@ require ( github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/backend/services/game-session-service/go.sum b/backend/services/game-session-service/go.sum index 1c4840f..a3d8fb8 100644 --- a/backend/services/game-session-service/go.sum +++ b/backend/services/game-session-service/go.sum @@ -7,7 +7,7 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= diff --git a/backend/services/gateway-service/go.mod b/backend/services/gateway-service/go.mod index 7bca70f..5630949 100644 --- a/backend/services/gateway-service/go.mod +++ b/backend/services/gateway-service/go.mod @@ -11,6 +11,7 @@ require ( github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/backend/services/gateway-service/go.sum b/backend/services/gateway-service/go.sum index 1c4840f..a3d8fb8 100644 --- a/backend/services/gateway-service/go.sum +++ b/backend/services/gateway-service/go.sum @@ -7,7 +7,7 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= diff --git a/backend/services/leaderboard-service/go.mod b/backend/services/leaderboard-service/go.mod index 238377c..f0ddea4 100644 --- a/backend/services/leaderboard-service/go.mod +++ b/backend/services/leaderboard-service/go.mod @@ -11,6 +11,7 @@ require ( github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/backend/services/leaderboard-service/go.sum b/backend/services/leaderboard-service/go.sum index 1c4840f..a3d8fb8 100644 --- a/backend/services/leaderboard-service/go.sum +++ b/backend/services/leaderboard-service/go.sum @@ -7,7 +7,7 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= diff --git a/backend/services/question-bank-service/go.mod b/backend/services/question-bank-service/go.mod index 647b6c4..3281976 100644 --- a/backend/services/question-bank-service/go.mod +++ b/backend/services/question-bank-service/go.mod @@ -11,6 +11,7 @@ require ( github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/backend/services/question-bank-service/go.sum b/backend/services/question-bank-service/go.sum index 1c4840f..a3d8fb8 100644 --- a/backend/services/question-bank-service/go.sum +++ b/backend/services/question-bank-service/go.sum @@ -7,7 +7,7 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= diff --git a/backend/services/user-service/go.mod b/backend/services/user-service/go.mod index 9de5e28..7439712 100644 --- a/backend/services/user-service/go.mod +++ b/backend/services/user-service/go.mod @@ -11,6 +11,7 @@ require ( github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/backend/services/user-service/go.sum b/backend/services/user-service/go.sum index 1c4840f..a3d8fb8 100644 --- a/backend/services/user-service/go.sum +++ b/backend/services/user-service/go.sum @@ -7,7 +7,7 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= diff --git a/backend/shared/domain/errors/errors_test.go b/backend/shared/domain/errors/errors_test.go new file mode 100644 index 0000000..42d3afd --- /dev/null +++ b/backend/shared/domain/errors/errors_test.go @@ -0,0 +1,40 @@ +package errors + +// Tests for domain error formatting, wrapping, and code matching. + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestDomainError_ErrorFormatting verifies error strings with and without wrapped errors. +func TestDomainError_ErrorFormatting(t *testing.T) { + base := New(CodeNotFound, "missing") + require.Equal(t, "[NOT_FOUND] missing", base.Error()) + + wrapped := Wrap(CodeInvalidInput, "bad", errors.New("details")) + require.Equal(t, "[INVALID_INPUT] bad: details", wrapped.Error()) +} + +// TestDomainError_Is ensures errors.Is matches on error code. +func TestDomainError_Is(t *testing.T) { + err := Wrap(CodeForbidden, "nope", nil) + target := New(CodeForbidden, "other") + require.True(t, errors.Is(err, target)) +} + +// TestDomainError_NewAndWrapFields verifies fields are set for New and Wrap. +func TestDomainError_NewAndWrapFields(t *testing.T) { + created := New(CodeConflict, "conflict") + require.Equal(t, CodeConflict, created.Code) + require.Equal(t, "conflict", created.Message) + require.Nil(t, created.Err) + + root := errors.New("root") + wrapped := Wrap(CodeInternal, "internal", root) + require.Equal(t, CodeInternal, wrapped.Code) + require.Equal(t, "internal", wrapped.Message) + require.Equal(t, root, wrapped.Unwrap()) +} diff --git a/backend/shared/domain/events/event_test.go b/backend/shared/domain/events/event_test.go new file mode 100644 index 0000000..553a9af --- /dev/null +++ b/backend/shared/domain/events/event_test.go @@ -0,0 +1,28 @@ +package events + +// Tests for event base fields and event type string behavior. + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestNewBaseEvent verifies event fields and timestamp range. +func TestNewBaseEvent(t *testing.T) { + before := time.Now() + base := NewBaseEvent(GameSessionStarted, "agg-1", "session") + after := time.Now() + + require.Equal(t, GameSessionStarted, base.EventType()) + require.Equal(t, "agg-1", base.AggregateID()) + require.Equal(t, "session", base.AggregateType()) + require.True(t, base.OccurredAt().After(before) || base.OccurredAt().Equal(before)) + require.True(t, base.OccurredAt().Before(after) || base.OccurredAt().Equal(after)) +} + +// TestEventType_String ensures event type string returns the underlying value. +func TestEventType_String(t *testing.T) { + require.Equal(t, "game_session.started", GameSessionStarted.String()) +} diff --git a/backend/shared/domain/types/id_test.go b/backend/shared/domain/types/id_test.go new file mode 100644 index 0000000..d6bc94a --- /dev/null +++ b/backend/shared/domain/types/id_test.go @@ -0,0 +1,23 @@ +package types + +// Tests for ID generation and UUID validation behavior. + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestID_NewIDIsValid ensures a new ID is non-empty and valid UUID. +func TestID_NewIDIsValid(t *testing.T) { + id := NewID() + require.False(t, id.IsEmpty()) + require.True(t, id.IsValid()) +} + +// TestID_IsValid verifies validity for empty, invalid, and generated IDs. +func TestID_IsValid(t *testing.T) { + require.False(t, ID("").IsValid()) + require.False(t, IDFromString("not-a-uuid").IsValid()) + require.True(t, NewID().IsValid()) +} diff --git a/backend/shared/domain/types/pagination_test.go b/backend/shared/domain/types/pagination_test.go new file mode 100644 index 0000000..f7e8d01 --- /dev/null +++ b/backend/shared/domain/types/pagination_test.go @@ -0,0 +1,32 @@ +package types + +// Tests for pagination limit, offset, normalization, and page navigation helpers. + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPagination_LimitOffsetNormalize verifies defaulting, clamping, and offset calculation. +func TestPagination_LimitOffsetNormalize(t *testing.T) { + p := Pagination{Page: 0, PageSize: 0} + require.Equal(t, 0, p.Offset()) + require.Equal(t, DefaultPageSize, p.Limit()) + + p = Pagination{Page: 2, PageSize: MaxPageSize + 10} + require.Equal(t, MaxPageSize, p.Limit()) + + p.Normalize() + require.Equal(t, 2, p.Page) + require.Equal(t, MaxPageSize, p.PageSize) +} + +// TestPaginatedResult verifies total pages and page navigation helpers. +func TestPaginatedResult(t *testing.T) { + pagination := Pagination{Page: 1, PageSize: 2} + result := NewPaginatedResult([]string{"a", "b"}, 3, pagination) + require.Equal(t, 2, result.TotalPages) + require.True(t, result.HasNextPage()) + require.False(t, result.HasPreviousPage()) +} diff --git a/backend/shared/domain/valueobjects/player_name_test.go b/backend/shared/domain/valueobjects/player_name_test.go new file mode 100644 index 0000000..58ce74a --- /dev/null +++ b/backend/shared/domain/valueobjects/player_name_test.go @@ -0,0 +1,55 @@ +package valueobjects + +// Tests for player name validation rules: length bounds, normalization, and character restrictions. + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + errs "knowfoolery/backend/shared/domain/errors" +) + +// TestNewPlayerName_NormalizesAndTrims verifies whitespace trimming and space normalization. +func TestNewPlayerName_NormalizesAndTrims(t *testing.T) { + name, err := NewPlayerName(" Alice Bob ") + require.NoError(t, err) + require.Equal(t, "Alice Bob", name.String()) +} + +// TestNewPlayerName_LengthValidation ensures too-short and too-long names are rejected. +func TestNewPlayerName_LengthValidation(t *testing.T) { + _, err := NewPlayerName("A") + require.Error(t, err) + + longName := strings.Repeat("a", MaxPlayerNameLength+1) + _, err = NewPlayerName(longName) + require.Error(t, err) +} + +// TestNewPlayerName_InvalidCharacters verifies disallowed characters yield an invalid player name error. +func TestNewPlayerName_InvalidCharacters(t *testing.T) { + _, err := NewPlayerName("Alice!") + require.Error(t, err) + + var domainErr *errs.DomainError + require.ErrorAs(t, err, &domainErr) + require.Equal(t, errs.CodeInvalidPlayerName, domainErr.Code) +} + +// TestPlayerName_EqualsCaseInsensitive confirms equality ignores case differences. +func TestPlayerName_EqualsCaseInsensitive(t *testing.T) { + name1, err := NewPlayerName("Alice") + require.NoError(t, err) + name2, err := NewPlayerName("alice") + require.NoError(t, err) + + require.True(t, name1.Equals(name2)) +} + +// TestPlayerName_IsEmpty confirms the zero value reports empty. +func TestPlayerName_IsEmpty(t *testing.T) { + var name PlayerName + require.True(t, name.IsEmpty()) +} diff --git a/backend/shared/domain/valueobjects/score_test.go b/backend/shared/domain/valueobjects/score_test.go new file mode 100644 index 0000000..607f7b1 --- /dev/null +++ b/backend/shared/domain/valueobjects/score_test.go @@ -0,0 +1,51 @@ +package valueobjects + +// Tests for score calculations, clamping, and attempt logic. + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestNewScore_ClampsNegativeToZero ensures negative inputs are clamped to zero. +func TestNewScore_ClampsNegativeToZero(t *testing.T) { + score := NewScore(-5) + require.Equal(t, 0, score.Value()) +} + +// TestScore_AddClampsBelowZero ensures adding negative points does not drop below zero. +func TestScore_AddClampsBelowZero(t *testing.T) { + score := NewScore(2) + updated := score.Add(-10) + require.Equal(t, 0, updated.Value()) +} + +// TestCalculateQuestionScore verifies scoring for correct/incorrect and hint usage. +func TestCalculateQuestionScore(t *testing.T) { + cases := []struct { + name string + correct bool + usedHint bool + expected int + }{ + {"incorrect", false, false, ScoreIncorrect}, + {"correct_with_hint", true, true, ScoreWithHint}, + {"correct_no_hint", true, false, MaxScorePerQuestion}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expected, CalculateQuestionScore(tc.correct, tc.usedHint)) + }) + } +} + +// TestAttempts verifies retry eligibility and remaining attempts calculation. +func TestAttempts(t *testing.T) { + require.True(t, CanRetry(MaxAttempts-1)) + require.False(t, CanRetry(MaxAttempts)) + + require.Equal(t, MaxAttempts-1, RemainingAttempts(1)) + require.Equal(t, 0, RemainingAttempts(MaxAttempts+1)) +} diff --git a/backend/shared/go.mod b/backend/shared/go.mod index efcee3c..a72e62c 100644 --- a/backend/shared/go.mod +++ b/backend/shared/go.mod @@ -8,21 +8,25 @@ require ( github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.20.5 github.com/rs/zerolog v1.33.0 + github.com/stretchr/testify v1.10.0 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect github.com/klauspost/compress v1.17.9 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect @@ -34,4 +38,5 @@ require ( golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/shared/go.sum b/backend/shared/go.sum index 936cfee..47d6059 100644 --- a/backend/shared/go.sum +++ b/backend/shared/go.sum @@ -5,6 +5,7 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -28,6 +29,9 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -49,11 +53,12 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= @@ -73,5 +78,6 @@ golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/shared/infra/auth/rbac/roles_test.go b/backend/shared/infra/auth/rbac/roles_test.go new file mode 100644 index 0000000..b111596 --- /dev/null +++ b/backend/shared/infra/auth/rbac/roles_test.go @@ -0,0 +1,43 @@ +package rbac + +// Tests for RBAC role permissions and role validation. + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRolePermissions verifies role permission checks for individual and aggregate queries. +func TestRolePermissions(t *testing.T) { + require.True(t, HasPermission(RolePlayer, PermissionPlayGame)) + require.False(t, HasPermission(RolePlayer, PermissionManageSystem)) + + require.True(t, HasAnyPermission(RoleModerator, PermissionViewUsers, PermissionManageSystem)) + require.False(t, HasAnyPermission(RolePlayer, PermissionManageSystem)) + + require.True(t, HasAllPermissions(RoleAdmin, PermissionManageSystem, PermissionViewDashboard)) + require.False(t, HasAllPermissions(RolePlayer, PermissionViewDashboard, PermissionPlayGame)) +} + +// TestUserHasPermission verifies role strings grant expected permissions. +func TestUserHasPermission(t *testing.T) { + require.True(t, UserHasPermission([]string{"player"}, PermissionPlayGame)) + require.False(t, UserHasPermission([]string{"player"}, PermissionManageSystem)) +} + +// TestGetPermissionsReturnsCopy ensures returned permission slices are not shared. +func TestGetPermissionsReturnsCopy(t *testing.T) { + perms := GetPermissions(RolePlayer) + require.NotEmpty(t, perms) + perms[0] = PermissionManageSystem + + fresh := GetPermissions(RolePlayer) + require.Equal(t, PermissionPlayGame, fresh[0]) +} + +// TestIsValidRole verifies known roles are accepted and unknown roles rejected. +func TestIsValidRole(t *testing.T) { + require.True(t, IsValidRole("player")) + require.False(t, IsValidRole("ghost")) +} diff --git a/backend/shared/infra/auth/zitadel/client_test.go b/backend/shared/infra/auth/zitadel/client_test.go new file mode 100644 index 0000000..673e829 --- /dev/null +++ b/backend/shared/infra/auth/zitadel/client_test.go @@ -0,0 +1,60 @@ +package zitadel + +// Tests for Zitadel client user info calls and placeholder methods. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestGetUserInfo_Success verifies user info retrieval on a 200 response. +func TestGetUserInfo_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer token", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(UserInfo{ + ID: "user-1", + Email: "a@b.com", + Name: "Alice", + Verified: true, + Roles: []string{"player"}, + }) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, Timeout: 2 * time.Second}) + info, err := client.GetUserInfo(context.Background(), "token") + require.NoError(t, err) + require.Equal(t, "user-1", info.ID) +} + +// TestGetUserInfo_NonOK verifies non-200 responses return an error. +func TestGetUserInfo_NonOK(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, Timeout: 2 * time.Second}) + _, err := client.GetUserInfo(context.Background(), "token") + require.Error(t, err) +} + +// TestClient_NotImplementedMethods verifies placeholder methods return errors. +func TestClient_NotImplementedMethods(t *testing.T) { + client := NewClient(DefaultConfig()) + + _, err := client.ValidateToken(context.Background(), "token") + require.Error(t, err) + + _, err = client.RefreshToken(context.Background(), "refresh") + require.Error(t, err) + + require.Error(t, client.RevokeToken(context.Background(), "token")) +} diff --git a/backend/shared/infra/auth/zitadel/middleware.go b/backend/shared/infra/auth/zitadel/middleware.go index 12ad40e..a496ad2 100644 --- a/backend/shared/infra/auth/zitadel/middleware.go +++ b/backend/shared/infra/auth/zitadel/middleware.go @@ -26,7 +26,7 @@ const ( // JWTMiddlewareConfig holds configuration for the JWT middleware. type JWTMiddlewareConfig struct { - Client *Client + Client TokenValidator Issuer string Audience string RequiredClaims []string @@ -34,6 +34,11 @@ type JWTMiddlewareConfig struct { SkipPaths []string } +// TokenValidator defines the interface for validating JWT tokens. +type TokenValidator interface { + ValidateToken(ctx context.Context, token string) (*AuthClaims, error) +} + // JWTMiddleware creates a Fiber middleware for JWT validation. func JWTMiddleware(config JWTMiddlewareConfig) fiber.Handler { return func(c fiber.Ctx) error { diff --git a/backend/shared/infra/auth/zitadel/middleware_test.go b/backend/shared/infra/auth/zitadel/middleware_test.go new file mode 100644 index 0000000..522fa6e --- /dev/null +++ b/backend/shared/infra/auth/zitadel/middleware_test.go @@ -0,0 +1,136 @@ +package zitadel + +// Tests for JWT middleware authentication, admin checks, and bypass paths. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/require" +) + +type fakeValidator struct { + claims *AuthClaims + err error + called int +} + +func (f *fakeValidator) ValidateToken(ctx context.Context, token string) (*AuthClaims, error) { + f.called++ + return f.claims, f.err +} + +// TestJWTMiddleware_Success verifies valid tokens populate context and allow requests. +func TestJWTMiddleware_Success(t *testing.T) { + validator := &fakeValidator{claims: &AuthClaims{ + Subject: "user-1", + Email: "a@b.com", + Name: "Alice", + Roles: []string{"player"}, + MFAVerified: true, + }} + + app := fiber.New() + app.Use(JWTMiddleware(JWTMiddlewareConfig{Client: validator})) + app.Get("/", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "user_id": GetUserID(c), + "email": GetUserEmail(c), + "roles": GetUserRoles(c), + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer token") + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + var body map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Equal(t, "user-1", body["user_id"]) +} + +// TestJWTMiddleware_MissingHeader verifies missing Authorization header returns 401. +func TestJWTMiddleware_MissingHeader(t *testing.T) { + validator := &fakeValidator{} + app := fiber.New() + app.Use(JWTMiddleware(JWTMiddlewareConfig{Client: validator})) + app.Get("/", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +// TestJWTMiddleware_InvalidHeaderFormat verifies malformed Authorization header returns 401. +func TestJWTMiddleware_InvalidHeaderFormat(t *testing.T) { + validator := &fakeValidator{} + app := fiber.New() + app.Use(JWTMiddleware(JWTMiddlewareConfig{Client: validator})) + app.Get("/", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Token abc") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +// TestJWTMiddleware_AdminRoleRequired ensures admin paths reject non-admin roles. +func TestJWTMiddleware_AdminRoleRequired(t *testing.T) { + validator := &fakeValidator{claims: &AuthClaims{ + Subject: "user-1", + Roles: []string{"player"}, + MFAVerified: true, + }} + + app := fiber.New() + app.Use(JWTMiddleware(JWTMiddlewareConfig{Client: validator, AdminEndpoints: []string{"/admin"}})) + app.Get("/admin/stats", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/admin/stats", nil) + req.Header.Set("Authorization", "Bearer token") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +// TestJWTMiddleware_MFARequiredForAdmin ensures admin paths require MFA verification. +func TestJWTMiddleware_MFARequiredForAdmin(t *testing.T) { + validator := &fakeValidator{claims: &AuthClaims{ + Subject: "user-1", + Roles: []string{"admin"}, + MFAVerified: false, + }} + + app := fiber.New() + app.Use(JWTMiddleware(JWTMiddlewareConfig{Client: validator, AdminEndpoints: []string{"/admin"}})) + app.Get("/admin/stats", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/admin/stats", nil) + req.Header.Set("Authorization", "Bearer token") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +// TestJWTMiddleware_SkipPath verifies skip paths bypass token validation. +func TestJWTMiddleware_SkipPath(t *testing.T) { + validator := &fakeValidator{err: fiber.ErrUnauthorized} + app := fiber.New() + app.Use(JWTMiddleware(JWTMiddlewareConfig{Client: validator, SkipPaths: []string{"/public"}})) + app.Get("/public/health", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/public/health", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 0, validator.called) +} diff --git a/backend/shared/infra/database/postgres/client_test.go b/backend/shared/infra/database/postgres/client_test.go new file mode 100644 index 0000000..4457985 --- /dev/null +++ b/backend/shared/infra/database/postgres/client_test.go @@ -0,0 +1,31 @@ +package postgres + +// Tests for PostgreSQL config defaults and connection string generation. + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestDefaultConfig verifies default configuration values for the client. +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + require.Equal(t, "localhost", cfg.Host) + require.Equal(t, 5432, cfg.Port) +} + +// TestConfigDSNAndURL verifies DSN and URL formatting include expected parts. +func TestConfigDSNAndURL(t *testing.T) { + cfg := DefaultConfig() + require.Contains(t, cfg.DSN(), "host=localhost") + require.Contains(t, cfg.URL(), "postgresql://") +} + +// TestHealthCheck verifies health checks delegate to Ping without error. +func TestHealthCheck(t *testing.T) { + client, err := NewClient(DefaultConfig()) + require.NoError(t, err) + require.NoError(t, client.HealthCheck(context.Background())) +} diff --git a/backend/shared/infra/database/redis/client_test.go b/backend/shared/infra/database/redis/client_test.go new file mode 100644 index 0000000..fdbf565 --- /dev/null +++ b/backend/shared/infra/database/redis/client_test.go @@ -0,0 +1,46 @@ +package redis + +// Tests for Redis config defaults, address formatting, and placeholder methods. + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestDefaultConfig verifies default configuration values for the client. +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + require.Equal(t, "localhost", cfg.Host) + require.Equal(t, 6379, cfg.Port) +} + +// TestAddr verifies Redis address formatting from config. +func TestAddr(t *testing.T) { + cfg := DefaultConfig() + require.Equal(t, "localhost:6379", cfg.Addr()) +} + +// TestHealthCheck verifies health checks delegate to Ping without error. +func TestHealthCheck(t *testing.T) { + client, err := NewClient(DefaultConfig()) + require.NoError(t, err) + require.NoError(t, client.HealthCheck(context.Background())) +} + +// TestNotImplemented verifies placeholder Redis methods return errors. +func TestNotImplemented(t *testing.T) { + client, err := NewClient(DefaultConfig()) + require.NoError(t, err) + + require.Error(t, client.Set(context.Background(), "k", "v", 0)) + _, err = client.Get(context.Background(), "k") + require.Error(t, err) + require.Error(t, client.Delete(context.Background(), "k")) + _, err = client.Exists(context.Background(), "k") + require.Error(t, err) + _, err = client.Incr(context.Background(), "k") + require.Error(t, err) + require.Error(t, client.Expire(context.Background(), "k", 0)) +} diff --git a/backend/shared/infra/observability/logging/logger_test.go b/backend/shared/infra/observability/logging/logger_test.go new file mode 100644 index 0000000..87fa3f3 --- /dev/null +++ b/backend/shared/infra/observability/logging/logger_test.go @@ -0,0 +1,32 @@ +package logging + +// Tests for logger construction and context-enriched loggers. + +import ( + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestNewLogger_NoPanic verifies logger creation handles invalid levels and nil output. +func TestNewLogger_NoPanic(t *testing.T) { + require.NotPanics(t, func() { + _ = NewLogger(Config{Level: "invalid", Environment: "development", Output: io.Discard}) + }) + + require.NotPanics(t, func() { + _ = NewLogger(Config{Level: "info", Environment: "production", Output: nil}) + }) +} + +// TestLogger_WithFields verifies field-enriched loggers are created. +func TestLogger_WithFields(t *testing.T) { + logger := NewLogger(DefaultConfig()) + withField := logger.WithField("key", "value") + withFields := logger.WithFields(map[string]interface{}{"a": 1}) + + require.NotNil(t, withField) + require.NotNil(t, withFields) + require.NotNil(t, logger.Zerolog()) +} diff --git a/backend/shared/infra/observability/metrics/prometheus.go b/backend/shared/infra/observability/metrics/prometheus.go index cbb0b4b..e52b836 100644 --- a/backend/shared/infra/observability/metrics/prometheus.go +++ b/backend/shared/infra/observability/metrics/prometheus.go @@ -10,6 +10,7 @@ import ( type Config struct { ServiceName string Enabled bool + Registry prometheus.Registerer } // DefaultConfig returns a default configuration. @@ -53,10 +54,16 @@ type Metrics struct { // NewMetrics creates a new Metrics instance with all metrics registered. func NewMetrics(config Config) *Metrics { + registry := config.Registry + if registry == nil { + registry = prometheus.DefaultRegisterer + } + auto := promauto.With(registry) + m := &Metrics{config: config} // HTTP metrics - m.HTTPRequestsTotal = promauto.NewCounterVec( + m.HTTPRequestsTotal = auto.NewCounterVec( prometheus.CounterOpts{ Name: "http_requests_total", Help: "Total number of HTTP requests", @@ -64,7 +71,7 @@ func NewMetrics(config Config) *Metrics { []string{"method", "endpoint", "status_code", "service"}, ) - m.HTTPRequestDuration = promauto.NewHistogramVec( + m.HTTPRequestDuration = auto.NewHistogramVec( prometheus.HistogramOpts{ Name: "http_request_duration_seconds", Help: "HTTP request duration", @@ -74,7 +81,7 @@ func NewMetrics(config Config) *Metrics { ) // Database metrics - m.DBConnectionsActive = promauto.NewGaugeVec( + m.DBConnectionsActive = auto.NewGaugeVec( prometheus.GaugeOpts{ Name: "db_connections_active", Help: "Number of active database connections", @@ -82,7 +89,7 @@ func NewMetrics(config Config) *Metrics { []string{"database", "service"}, ) - m.DBQueryDuration = promauto.NewHistogramVec( + m.DBQueryDuration = auto.NewHistogramVec( prometheus.HistogramOpts{ Name: "db_query_duration_seconds", Help: "Database query duration", @@ -91,7 +98,7 @@ func NewMetrics(config Config) *Metrics { []string{"query_type", "table", "service"}, ) - m.DBErrors = promauto.NewCounterVec( + m.DBErrors = auto.NewCounterVec( prometheus.CounterOpts{ Name: "db_errors_total", Help: "Total number of database errors", @@ -100,7 +107,7 @@ func NewMetrics(config Config) *Metrics { ) // Cache metrics - m.CacheOperations = promauto.NewCounterVec( + m.CacheOperations = auto.NewCounterVec( prometheus.CounterOpts{ Name: "cache_operations_total", Help: "Total number of cache operations", @@ -108,7 +115,7 @@ func NewMetrics(config Config) *Metrics { []string{"operation", "result", "service"}, ) - m.CacheKeyCount = promauto.NewGaugeVec( + m.CacheKeyCount = auto.NewGaugeVec( prometheus.GaugeOpts{ Name: "cache_keys_total", Help: "Number of keys in cache", @@ -117,7 +124,7 @@ func NewMetrics(config Config) *Metrics { ) // Authentication metrics - m.AuthAttempts = promauto.NewCounterVec( + m.AuthAttempts = auto.NewCounterVec( prometheus.CounterOpts{ Name: "authentication_attempts_total", Help: "Total authentication attempts", @@ -125,7 +132,7 @@ func NewMetrics(config Config) *Metrics { []string{"method", "result", "user_type"}, ) - m.TokenOperations = promauto.NewCounterVec( + m.TokenOperations = auto.NewCounterVec( prometheus.CounterOpts{ Name: "token_operations_total", Help: "JWT token operations", @@ -134,7 +141,7 @@ func NewMetrics(config Config) *Metrics { ) // Game metrics - m.GamesStarted = promauto.NewCounterVec( + m.GamesStarted = auto.NewCounterVec( prometheus.CounterOpts{ Name: "games_started_total", Help: "Total number of games started", @@ -142,7 +149,7 @@ func NewMetrics(config Config) *Metrics { []string{"player_type", "platform"}, ) - m.GamesCompleted = promauto.NewCounterVec( + m.GamesCompleted = auto.NewCounterVec( prometheus.CounterOpts{ Name: "games_completed_total", Help: "Total number of games completed", @@ -150,7 +157,7 @@ func NewMetrics(config Config) *Metrics { []string{"completion_type", "platform"}, ) - m.SessionDuration = promauto.NewHistogramVec( + m.SessionDuration = auto.NewHistogramVec( prometheus.HistogramOpts{ Name: "game_session_duration_seconds", Help: "Duration of game sessions", @@ -159,7 +166,7 @@ func NewMetrics(config Config) *Metrics { []string{"completion_type"}, ) - m.QuestionsAsked = promauto.NewCounterVec( + m.QuestionsAsked = auto.NewCounterVec( prometheus.CounterOpts{ Name: "questions_asked_total", Help: "Total number of questions asked", @@ -167,7 +174,7 @@ func NewMetrics(config Config) *Metrics { []string{"theme", "difficulty"}, ) - m.AnswersSubmitted = promauto.NewCounterVec( + m.AnswersSubmitted = auto.NewCounterVec( prometheus.CounterOpts{ Name: "answers_submitted_total", Help: "Total number of answers submitted", @@ -175,7 +182,7 @@ func NewMetrics(config Config) *Metrics { []string{"theme", "is_correct", "attempt_number", "used_hint"}, ) - m.HintsRequested = promauto.NewCounterVec( + m.HintsRequested = auto.NewCounterVec( prometheus.CounterOpts{ Name: "hints_requested_total", Help: "Total number of hints requested", @@ -183,7 +190,7 @@ func NewMetrics(config Config) *Metrics { []string{"theme", "question_difficulty"}, ) - m.ScoreDistribution = promauto.NewHistogramVec( + m.ScoreDistribution = auto.NewHistogramVec( prometheus.HistogramOpts{ Name: "game_scores", Help: "Distribution of game scores", diff --git a/backend/shared/infra/observability/metrics/prometheus_test.go b/backend/shared/infra/observability/metrics/prometheus_test.go new file mode 100644 index 0000000..bbe9c4d --- /dev/null +++ b/backend/shared/infra/observability/metrics/prometheus_test.go @@ -0,0 +1,21 @@ +package metrics + +// Tests for Prometheus metrics registration with a custom registry. + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" +) + +// TestNewMetrics_WithCustomRegistry verifies metrics register on a provided registry. +func TestNewMetrics_WithCustomRegistry(t *testing.T) { + registry := prometheus.NewRegistry() + m := NewMetrics(Config{ServiceName: "svc", Enabled: true, Registry: registry}) + + require.NotNil(t, m) + require.NotNil(t, m.HTTPRequestsTotal) + require.NotNil(t, m.DBConnectionsActive) + require.NotNil(t, m.ScoreDistribution) +} diff --git a/backend/shared/infra/observability/tracing/tracer_test.go b/backend/shared/infra/observability/tracing/tracer_test.go new file mode 100644 index 0000000..6607580 --- /dev/null +++ b/backend/shared/infra/observability/tracing/tracer_test.go @@ -0,0 +1,37 @@ +package tracing + +// Tests for tracing helpers invoking operations and propagating errors. + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestTraceServiceOperation verifies tracing wraps a service operation and returns errors. +func TestTraceServiceOperation(t *testing.T) { + tracer, err := NewTracer(DefaultConfig()) + require.NoError(t, err) + + expected := errors.New("boom") + err = TraceServiceOperation(context.Background(), tracer, "svc", "op", func(ctx context.Context) error { + return expected + }) + require.Equal(t, expected, err) +} + +// TestTraceDatabaseOperation verifies tracing wraps a database operation and calls the function. +func TestTraceDatabaseOperation(t *testing.T) { + tracer, err := NewTracer(DefaultConfig()) + require.NoError(t, err) + + called := false + err = TraceDatabaseOperation(context.Background(), tracer, "select", "users", func(ctx context.Context) error { + called = true + return nil + }) + require.NoError(t, err) + require.True(t, called) +} diff --git a/backend/shared/infra/security/sanitize_test.go b/backend/shared/infra/security/sanitize_test.go new file mode 100644 index 0000000..7598076 --- /dev/null +++ b/backend/shared/infra/security/sanitize_test.go @@ -0,0 +1,69 @@ +package security + +// Tests for input sanitization utilities and validation helpers. + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestSanitize_Options verifies trimming, space collapsing, escaping, max length, and allowed patterns. +func TestSanitize_Options(t *testing.T) { + opts := SanitizeOptions{ + TrimWhitespace: true, + RemoveMultipleSpaces: true, + HTMLEscape: true, + MaxLength: 0, + AllowedPattern: nil, + } + + result := Sanitize(" Hello World ", opts) + require.Equal(t, "Hello <b>World</b>", result) + + opts.MaxLength = 5 + require.Equal(t, "Hello", Sanitize("Hello World", opts)) + + opts.AllowedPattern = regexp.MustCompile(`^[a-z]+$`) + require.Equal(t, "", Sanitize("Hello123", opts)) +} + +// TestSanitizePlayerName verifies player name normalization and invalid input rejection. +func TestSanitizePlayerName(t *testing.T) { + require.Equal(t, "Alice Bob", SanitizePlayerName(" Alice Bob ")) + require.Equal(t, "", SanitizePlayerName("Alice <")) +} + +// TestSanitizeAnswer verifies lowercasing and whitespace normalization. +func TestSanitizeAnswer(t *testing.T) { + require.Equal(t, "hello", SanitizeAnswer(" HeLLo ")) +} + +// TestSanitizeQuestionText verifies script tags are removed from question text. +func TestSanitizeQuestionText(t *testing.T) { + result := SanitizeQuestionText(" Question") + require.NotContains(t, result, "Hi")) +} + +// TestContainsDangerousPatterns detects known dangerous substrings. +func TestContainsDangerousPatterns(t *testing.T) { + require.True(t, ContainsDangerousPatterns("javascript:alert(1)")) + require.False(t, ContainsDangerousPatterns("hello")) +} + +// TestIsValidEmail verifies basic email pattern validation. +func TestIsValidEmail(t *testing.T) { + require.True(t, IsValidEmail("a@b.com")) + require.False(t, IsValidEmail("bad@")) +} diff --git a/backend/shared/infra/utils/httputil/errors_test.go b/backend/shared/infra/utils/httputil/errors_test.go new file mode 100644 index 0000000..5a527db --- /dev/null +++ b/backend/shared/infra/utils/httputil/errors_test.go @@ -0,0 +1,64 @@ +package httputil + +// Tests for HTTP error mapping and response payloads. + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/require" + + errorspkg "knowfoolery/backend/shared/domain/errors" +) + +// TestMapError verifies domain error codes map to HTTP status codes and response codes. +func TestMapError(t *testing.T) { + cases := []struct { + name string + err error + statusCode int + code string + }{ + {"nil", nil, http.StatusInternalServerError, "INTERNAL"}, + {"not_found", errorspkg.Wrap(errorspkg.CodeNotFound, "missing", nil), http.StatusNotFound, "NOT_FOUND"}, + {"invalid", errorspkg.Wrap(errorspkg.CodeInvalidInput, "bad", nil), http.StatusBadRequest, "INVALID_INPUT"}, + {"unauthorized", errorspkg.Wrap(errorspkg.CodeUnauthorized, "no", nil), http.StatusUnauthorized, "UNAUTHORIZED"}, + {"forbidden", errorspkg.Wrap(errorspkg.CodeForbidden, "no", nil), http.StatusForbidden, "FORBIDDEN"}, + {"conflict", errorspkg.Wrap(errorspkg.CodeConflict, "conflict", nil), http.StatusConflict, "CONFLICT"}, + {"rate_limit", errorspkg.Wrap(errorspkg.CodeRateLimitExceeded, "slow", nil), http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED"}, + {"gone", errorspkg.Wrap(errorspkg.CodeSessionExpired, "gone", nil), http.StatusGone, "SESSION_EXPIRED"}, + {"unprocessable", errorspkg.Wrap(errorspkg.CodeMaxAttemptsReached, "max", nil), http.StatusUnprocessableEntity, "MAX_ATTEMPTS_REACHED"}, + {"generic", errors.New("boom"), http.StatusInternalServerError, "INTERNAL"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + status, resp := MapError(tc.err) + require.Equal(t, tc.statusCode, status) + require.Equal(t, tc.code, resp.Code) + }) + } +} + +// TestSendError verifies SendError writes the correct status and payload. +func TestSendError(t *testing.T) { + app := fiber.New() + app.Get("/", func(c fiber.Ctx) error { + return SendError(c, errorspkg.ErrNotFound) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) + + var body ErrorResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Equal(t, "NOT_FOUND", body.Code) +} diff --git a/backend/shared/infra/utils/httputil/pagination_test.go b/backend/shared/infra/utils/httputil/pagination_test.go new file mode 100644 index 0000000..0499d3b --- /dev/null +++ b/backend/shared/infra/utils/httputil/pagination_test.go @@ -0,0 +1,74 @@ +package httputil + +// Tests for HTTP query pagination, sorting, and filtering extraction. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/require" + + "knowfoolery/backend/shared/domain/types" +) + +// TestPaginationFromQuery verifies defaulting and bounds from query parameters. +func TestPaginationFromQuery(t *testing.T) { + app := fiber.New() + app.Get("/", func(c fiber.Ctx) error { + pagination := PaginationFromQuery(c) + return c.JSON(pagination) + }) + + req := httptest.NewRequest(http.MethodGet, "/?page=0&page_size=500", nil) + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + var p types.Pagination + require.NoError(t, json.NewDecoder(resp.Body).Decode(&p)) + require.Equal(t, 1, p.Page) + require.Equal(t, types.MaxPageSize, p.PageSize) +} + +// TestSortingFromQuery verifies allowed field enforcement and direction normalization. +func TestSortingFromQuery(t *testing.T) { + app := fiber.New() + app.Get("/", func(c fiber.Ctx) error { + sorting := SortingFromQuery(c, "name", []string{"name", "created"}) + return c.JSON(sorting) + }) + + req := httptest.NewRequest(http.MethodGet, "/?sort=invalid&direction=down", nil) + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + var s SortingParams + require.NoError(t, json.NewDecoder(resp.Body).Decode(&s)) + require.Equal(t, "name", s.Field) + require.Equal(t, "asc", s.Direction) +} + +// TestFiltersFromQueryAndCustom verifies base filters and custom filter extraction. +func TestFiltersFromQueryAndCustom(t *testing.T) { + app := fiber.New() + app.Get("/", func(c fiber.Ctx) error { + filters := FiltersFromQuery(c) + filters.WithCustomFilter(c, "theme") + return c.JSON(filters) + }) + + req := httptest.NewRequest(http.MethodGet, "/?search=hi&status=active&theme=scifi", nil) + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + var f FilterParams + require.NoError(t, json.NewDecoder(resp.Body).Decode(&f)) + require.Equal(t, "hi", f.Search) + require.Equal(t, "active", f.Status) + require.Equal(t, "scifi", f.Custom["theme"]) +} diff --git a/backend/shared/infra/utils/httputil/response_test.go b/backend/shared/infra/utils/httputil/response_test.go new file mode 100644 index 0000000..024e45a --- /dev/null +++ b/backend/shared/infra/utils/httputil/response_test.go @@ -0,0 +1,85 @@ +package httputil + +// Tests for HTTP response helpers and health status derivation. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/require" +) + +// TestNewPaginatedResponse verifies total page calculation in paginated responses. +func TestNewPaginatedResponse(t *testing.T) { + resp := NewPaginatedResponse([]string{"a"}, 1, 2, 3) + require.NotNil(t, resp.Meta) + require.Equal(t, 2, resp.Meta.TotalPages) +} + +// TestHealthStatus verifies health status flips to unhealthy when any check fails. +func TestHealthStatus(t *testing.T) { + app := fiber.New() + app.Get("/health", func(c fiber.Ctx) error { + return Health(c, "service", "0.1.0", map[string]string{ + "db": "ok", + "cache": "down", + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + var body HealthResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Equal(t, "unhealthy", body.Status) +} + +// TestResponseHelpers verifies status codes for OK, Created, NoContent, Paginated, and Message. +func TestResponseHelpers(t *testing.T) { + app := fiber.New() + app.Get("/ok", func(c fiber.Ctx) error { + return OK(c, fiber.Map{"ok": true}) + }) + app.Post("/created", func(c fiber.Ctx) error { + return Created(c, fiber.Map{"id": "1"}) + }) + app.Delete("/no-content", func(c fiber.Ctx) error { + return NoContent(c) + }) + app.Get("/paginated", func(c fiber.Ctx) error { + return Paginated(c, []string{"a"}, 1, 2, 3) + }) + app.Get("/message", func(c fiber.Ctx) error { + return Message(c, "hello") + }) + + req := httptest.NewRequest(http.MethodGet, "/ok", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + req = httptest.NewRequest(http.MethodPost, "/created", nil) + resp, err = app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + req = httptest.NewRequest(http.MethodDelete, "/no-content", nil) + resp, err = app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + + req = httptest.NewRequest(http.MethodGet, "/paginated", nil) + resp, err = app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + req = httptest.NewRequest(http.MethodGet, "/message", nil) + resp, err = app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/backend/shared/infra/utils/validation/validator_test.go b/backend/shared/infra/utils/validation/validator_test.go new file mode 100644 index 0000000..adf5064 --- /dev/null +++ b/backend/shared/infra/utils/validation/validator_test.go @@ -0,0 +1,61 @@ +package validation + +// Tests for custom validation tags and error formatting behavior. + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + errs "knowfoolery/backend/shared/domain/errors" +) + +type sampleStruct struct { + Name string `json:"name" validate:"required"` +} + +type playerStruct struct { + PlayerName string `json:"player_name" validate:"required,player_name"` +} + +// TestValidator_CustomTags verifies custom validation tags reject invalid inputs. +func TestValidator_CustomTags(t *testing.T) { + v := NewValidator() + + require.Error(t, v.ValidateVar("bad!", "alphanum_space")) + require.Error(t, v.ValidateVar("hi", "no_html")) + require.Error(t, v.ValidateVar("javascript:alert(1)", "safe_text")) + require.Error(t, v.ValidateVar("A", "player_name")) +} + +// TestValidator_ValidateReturnsDomainError ensures struct validation returns a domain error with messages. +func TestValidator_ValidateReturnsDomainError(t *testing.T) { + v := NewValidator() + err := v.Validate(sampleStruct{}) + require.Error(t, err) + + var domainErr *errs.DomainError + require.ErrorAs(t, err, &domainErr) + require.Equal(t, errs.CodeValidationFailed, domainErr.Code) + require.True(t, strings.Contains(domainErr.Message, "name is required")) +} + +// TestValidator_ValidateVarReturnsDomainError ensures field validation returns a domain error. +func TestValidator_ValidateVarReturnsDomainError(t *testing.T) { + v := NewValidator() + err := v.ValidateVar("bad", "email") + require.Error(t, err) + + var domainErr *errs.DomainError + require.ErrorAs(t, err, &domainErr) + require.Equal(t, errs.CodeValidationFailed, domainErr.Code) +} + +// TestValidator_JSONTagNameMapping ensures JSON tag names appear in validation error text. +func TestValidator_JSONTagNameMapping(t *testing.T) { + v := NewValidator() + err := v.Validate(playerStruct{PlayerName: "A"}) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "player_name must be a valid player name")) +}