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, "