diff --git a/cmd/swat4master/application/application.go b/cmd/swat4master/application/application.go index 1ebab08..13861de 100644 --- a/cmd/swat4master/application/application.go +++ b/cmd/swat4master/application/application.go @@ -9,8 +9,8 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/metrics" "github.com/sergeii/swat4master/internal/persistence/memory/instances" - "github.com/sergeii/swat4master/internal/persistence/memory/probes" "github.com/sergeii/swat4master/internal/persistence/memory/servers" + "github.com/sergeii/swat4master/internal/persistence/redis/probes" "github.com/sergeii/swat4master/internal/validation" ) diff --git a/cmd/swat4master/config/config.go b/cmd/swat4master/config/config.go index 22c9f22..dad8db9 100644 --- a/cmd/swat4master/config/config.go +++ b/cmd/swat4master/config/config.go @@ -14,6 +14,8 @@ type Config struct { LogLevel string LogOutput string + RedisURL string + ReporterListenAddr string ReporterBufferSize int @@ -63,6 +65,10 @@ func Provide() Config { &cfg.LogOutput, "log.output", "console", "Output format of log messages. Available options: console, stdout, json", ) + flag.StringVar( + &cfg.RedisURL, "redis.url", "redis://localhost:6379", + "URL to connect to the Redis server", + ) flag.StringVar( &cfg.ReporterListenAddr, "reporter.address", ":27900", "Address to listen on for the reporter service", @@ -152,7 +158,7 @@ func Provide() Config { "Determines how many times a failed revival probe is retried", ) flag.DurationVar( - &cfg.ProbePollSchedule, "probe.schedule", time.Millisecond*50, + &cfg.ProbePollSchedule, "probe.schedule", time.Millisecond*250, "Defines how often the discovery queue is checked for new probes", ) flag.DurationVar( diff --git a/cmd/swat4master/main.go b/cmd/swat4master/main.go index 8cd44ae..9cac602 100644 --- a/cmd/swat4master/main.go +++ b/cmd/swat4master/main.go @@ -19,6 +19,7 @@ import ( "github.com/sergeii/swat4master/cmd/swat4master/modules/refresher" "github.com/sergeii/swat4master/cmd/swat4master/modules/reporter" "github.com/sergeii/swat4master/cmd/swat4master/modules/reviver" + "github.com/sergeii/swat4master/cmd/swat4master/persistence" "github.com/sergeii/swat4master/cmd/swat4master/application" ) @@ -26,6 +27,7 @@ import ( func main() { app := fx.New( fx.Provide(config.Provide), + fx.Provide(persistence.Provide), application.Module, refresher.Module, reviver.Module, diff --git a/cmd/swat4master/persistence/persistence.go b/cmd/swat4master/persistence/persistence.go new file mode 100644 index 0000000..d193d03 --- /dev/null +++ b/cmd/swat4master/persistence/persistence.go @@ -0,0 +1,37 @@ +package persistence + +import ( + "context" + + "github.com/redis/go-redis/v9" + "go.uber.org/fx" + + "github.com/sergeii/swat4master/cmd/swat4master/config" +) + +type Persistence struct { + fx.Out + + RedisClient *redis.Client +} + +func Provide(cfg config.Config, lc fx.Lifecycle) (Persistence, error) { + opts, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + return Persistence{}, err + } + + redisClient := redis.NewClient(opts) + + lc.Append(fx.Hook{ + OnStop: func(_ context.Context) error { + return redisClient.Close() + }, + }) + + persistence := Persistence{ + RedisClient: redisClient, + } + + return persistence, nil +} diff --git a/go.mod b/go.mod index e0b60fd..3959aa5 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,28 @@ module github.com/sergeii/swat4master go 1.23 require ( + github.com/alicebob/miniredis/v2 v2.34.0 github.com/gin-gonic/gin v1.10.0 github.com/go-playground/validator/v10 v10.23.0 + github.com/google/uuid v1.6.0 github.com/gosimple/slug v1.15.0 github.com/jonboulle/clockwork v0.5.0 github.com/prometheus/client_golang v1.20.5 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.61.0 + github.com/redis/go-redis/v9 v9.7.0 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/swag v1.16.3 go.uber.org/fx v1.23.0 golang.org/x/text v0.21.0 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.11.9 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect @@ -27,6 +32,7 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -53,9 +59,9 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/swaggo/swag v1.16.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/dig v1.18.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect diff --git a/go.sum b/go.sum index 90b02be..ef31273 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,15 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0= +github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg= github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -16,6 +24,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= @@ -46,6 +56,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= @@ -98,6 +110,8 @@ github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFS github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= 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/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -128,6 +142,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw= go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= diff --git a/internal/core/entities/addr/addr.go b/internal/core/entities/addr/addr.go index 1660920..9c71da4 100644 --- a/internal/core/entities/addr/addr.go +++ b/internal/core/entities/addr/addr.go @@ -9,8 +9,8 @@ import ( ) type Addr struct { - IP [4]byte - Port int + IP [4]byte `json:"ip"` + Port int `json:"port"` } var Blank Addr // nolint: gochecknoglobals diff --git a/internal/core/entities/probe/probe.go b/internal/core/entities/probe/probe.go index 1abf628..4279335 100644 --- a/internal/core/entities/probe/probe.go +++ b/internal/core/entities/probe/probe.go @@ -27,11 +27,11 @@ func (goal Goal) String() string { var NC = time.Time{} // no constraint type Probe struct { - Addr addr.Addr - Port int - Goal Goal - Retries int - MaxRetries int + Addr addr.Addr `json:"addr"` + Port int `json:"port"` + Goal Goal `json:"goal"` + Retries int `json:"retries"` + MaxRetries int `json:"max_retries"` } var Blank Probe // nolint: gochecknoglobals diff --git a/internal/core/entities/server/server_test.go b/internal/core/entities/server/server_test.go index c0fbcb6..dd520eb 100644 --- a/internal/core/entities/server/server_test.go +++ b/internal/core/entities/server/server_test.go @@ -12,6 +12,7 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/details" ds "github.com/sergeii/swat4master/internal/core/entities/discovery/status" "github.com/sergeii/swat4master/internal/core/entities/server" + "github.com/sergeii/swat4master/internal/testutils/factories/infofactory" ) func TestServer_New(t *testing.T) { @@ -161,14 +162,16 @@ func TestServer_InfoIsUpdated(t *testing.T) { svr := server.MustNew(net.ParseIP("1.1.1.1"), 10480, 10481) assert.Equal(t, "", svr.Info.Hostname) - newInfo := details.MustNewInfoFromParams(map[string]string{ - "hostname": "Swat4 Server", - "hostport": "10480", - "mapname": "A-Bomb Nightclub", - "gamever": "1.1", - "gamevariant": "SWAT 4", - "gametype": "Barricaded Suspects", - }) + newInfo := infofactory.Build(infofactory.WithFields( + infofactory.F{ + "hostname": "Swat4 Server", + "hostport": "10480", + "mapname": "A-Bomb Nightclub", + "gamever": "1.1", + "gamevariant": "SWAT 4", + "gametype": "Barricaded Suspects", + }, + )) svr.UpdateInfo(newInfo, time.Now()) updatedInfo := svr.Info diff --git a/internal/core/repositories/probe.go b/internal/core/repositories/probe.go index 41a4faf..67b8df7 100644 --- a/internal/core/repositories/probe.go +++ b/internal/core/repositories/probe.go @@ -8,11 +8,7 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/probe" ) -var ( - ErrProbeQueueIsEmpty = errors.New("queue is empty") - ErrProbeIsNotReady = errors.New("queue has waiting probes") - ErrProbeHasExpired = errors.New("probe has expired") -) +var ErrProbeQueueIsEmpty = errors.New("queue is empty") var NC = time.Time{} // no constraint @@ -20,7 +16,7 @@ type ProbeRepository interface { Add(context.Context, probe.Probe) error AddBetween(context.Context, probe.Probe, time.Time, time.Time) error Pop(context.Context) (probe.Probe, error) - PopAny(context.Context) (probe.Probe, error) + Peek(context.Context) (probe.Probe, error) PopMany(context.Context, int) ([]probe.Probe, int, error) Count(context.Context) (int, error) } diff --git a/internal/core/usecases/addserver/addserver_test.go b/internal/core/usecases/addserver/addserver_test.go index 83f7ee2..0783a14 100644 --- a/internal/core/usecases/addserver/addserver_test.go +++ b/internal/core/usecases/addserver/addserver_test.go @@ -17,7 +17,7 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/core/usecases/addserver" "github.com/sergeii/swat4master/internal/metrics" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -133,9 +133,9 @@ func TestAddServerUseCase_ServerExists(t *testing.T) { logger := zerolog.Nop() collector := metrics.New() - svr := factories.BuildServer( - factories.WithAddress("1.1.1.1", 10480), - factories.WithDiscoveryStatus(tt.status), + svr := serverfactory.Build( + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithDiscoveryStatus(tt.status), ) serverRepo := new(MockServerRepository) @@ -200,7 +200,7 @@ func TestAddServerUseCase_ServerDoesNotExist(t *testing.T) { logger := zerolog.Nop() collector := metrics.New() - newSvr := factories.BuildServer(factories.WithAddress("1.1.1.1", 10480)) + newSvr := serverfactory.Build(serverfactory.WithAddress("1.1.1.1", 10480)) serverRepo := new(MockServerRepository) serverRepo.On("Get", ctx, newSvr.Addr).Return(server.Blank, repositories.ErrServerNotFound) diff --git a/internal/core/usecases/cleanservers/cleanservers_test.go b/internal/core/usecases/cleanservers/cleanservers_test.go index ec95b9b..79303cb 100644 --- a/internal/core/usecases/cleanservers/cleanservers_test.go +++ b/internal/core/usecases/cleanservers/cleanservers_test.go @@ -15,7 +15,7 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/core/usecases/cleanservers" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -62,8 +62,8 @@ func TestCleanServersUseCase_Success(t *testing.T) { until := time.Now().Add(-24 * time.Hour) // Example time filter outdatedServers := []server.Server{ - factories.BuildRandomServer(), - factories.BuildRandomServer(), + serverfactory.BuildRandom(), + serverfactory.BuildRandom(), } serverRepo := new(MockServerRepository) @@ -132,9 +132,9 @@ func TestCleanServersUseCase_RemoveErrors(t *testing.T) { until := time.Now().Add(-24 * time.Hour) // Example time filter - svr1 := factories.BuildRandomServer() - svr2 := factories.BuildRandomServer() - svr3 := factories.BuildRandomServer() + svr1 := serverfactory.BuildRandom() + svr2 := serverfactory.BuildRandom() + svr3 := serverfactory.BuildRandom() outdatedServers := []server.Server{svr1, svr2, svr3} serverRepo := new(MockServerRepository) diff --git a/internal/core/usecases/getserver/getserver_test.go b/internal/core/usecases/getserver/getserver_test.go index 3f5ca52..15bae20 100644 --- a/internal/core/usecases/getserver/getserver_test.go +++ b/internal/core/usecases/getserver/getserver_test.go @@ -12,7 +12,7 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/core/usecases/getserver" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -28,7 +28,7 @@ func (m *MockServerRepository) Get(ctx context.Context, addr addr.Addr) (server. func TestGetServerUseCase_OK(t *testing.T) { ctx := context.TODO() - svr := factories.BuildServer(factories.WithDiscoveryStatus(ds.Details)) + svr := serverfactory.Build(serverfactory.WithDiscoveryStatus(ds.Details)) mockRepo := new(MockServerRepository) mockRepo.On("Get", ctx, svr.Addr).Return(svr, nil) @@ -100,9 +100,9 @@ func TestGetServerUseCase_ValidateStatus(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.TODO() - svr := factories.BuildServer( - factories.WithAddress("1.1.1.1", 10480), - factories.WithDiscoveryStatus(tt.status), + svr := serverfactory.Build( + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithDiscoveryStatus(tt.status), ) mockRepo := new(MockServerRepository) diff --git a/internal/core/usecases/listservers/listservers_test.go b/internal/core/usecases/listservers/listservers_test.go index c15ff5b..a9506f4 100644 --- a/internal/core/usecases/listservers/listservers_test.go +++ b/internal/core/usecases/listservers/listservers_test.go @@ -15,7 +15,7 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/core/usecases/listservers" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" "github.com/sergeii/swat4master/pkg/gamespy/browsing/query" "github.com/sergeii/swat4master/pkg/gamespy/browsing/query/filter" ) @@ -84,8 +84,8 @@ func TestListServersUseCase_FilterParams(t *testing.T) { clock := clockwork.NewFakeClock() repoServers := []server.Server{ - factories.BuildRandomServer(), - factories.BuildRandomServer(), + serverfactory.BuildRandom(), + serverfactory.BuildRandom(), } mockRepo := new(MockServerRepository) @@ -329,11 +329,11 @@ func TestListServersUseCase_FilterByQuery(t *testing.T) { }, } - vip := factories.BuildServer( - factories.WithAddress("1.1.1.1", 10580), - factories.WithQueryPort(10581), - factories.WithDiscoveryStatus(ds.Master|ds.Info), - factories.WithInfo(map[string]string{ + vip := serverfactory.Build( + serverfactory.WithAddress("1.1.1.1", 10580), + serverfactory.WithQueryPort(10581), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info), + serverfactory.WithInfo(map[string]string{ "hostname": "VIP Escort Swat4 Server", "hostport": "10480", "gametype": "VIP Escort", @@ -346,11 +346,11 @@ func TestListServersUseCase_FilterByQuery(t *testing.T) { }), ) - vip10 := factories.BuildServer( - factories.WithAddress("2.2.2.2", 10580), - factories.WithQueryPort(10581), - factories.WithDiscoveryStatus(ds.Master|ds.Info), - factories.WithInfo(map[string]string{ + vip10 := serverfactory.Build( + serverfactory.WithAddress("2.2.2.2", 10580), + serverfactory.WithQueryPort(10581), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info), + serverfactory.WithInfo(map[string]string{ "hostname": "VIP 1.0 Swat4 Server", "hostport": "10480", "gametype": "VIP Escort", @@ -363,11 +363,11 @@ func TestListServersUseCase_FilterByQuery(t *testing.T) { }), ) - bs := factories.BuildServer( - factories.WithAddress("3.3.3.3", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info|ds.Details), - factories.WithInfo(map[string]string{ + bs := serverfactory.Build( + serverfactory.WithAddress("3.3.3.3", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info|ds.Details), + serverfactory.WithInfo(map[string]string{ "hostname": "BS Swat4 Server", "hostport": "10480", "gametype": "Barricaded Suspects", @@ -380,11 +380,11 @@ func TestListServersUseCase_FilterByQuery(t *testing.T) { }), ) - coop := factories.BuildServer( - factories.WithAddress("4.4.4.4", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Info|ds.Details), - factories.WithInfo(map[string]string{ + coop := serverfactory.Build( + serverfactory.WithAddress("4.4.4.4", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Info|ds.Details), + serverfactory.WithInfo(map[string]string{ "hostname": "COOP Swat4 Server", "hostport": "10480", "gametype": "CO-OP", @@ -397,11 +397,11 @@ func TestListServersUseCase_FilterByQuery(t *testing.T) { }), ) - sg := factories.BuildServer( - factories.WithAddress("5.5.5.5", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info|ds.NoDetails), - factories.WithInfo(map[string]string{ + sg := serverfactory.Build( + serverfactory.WithAddress("5.5.5.5", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info|ds.NoDetails), + serverfactory.WithInfo(map[string]string{ "hostname": "S&G Swat4 Server", "hostport": "10480", "gametype": "Smash And Grab", @@ -414,11 +414,11 @@ func TestListServersUseCase_FilterByQuery(t *testing.T) { }), ) - coopx := factories.BuildServer( - factories.WithAddress("6.6.6.6", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info), - factories.WithInfo(map[string]string{ + coopx := serverfactory.Build( + serverfactory.WithAddress("6.6.6.6", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info), + serverfactory.WithInfo(map[string]string{ "hostname": "TSS COOP Swat4 Server", "hostport": "10480", "gametype": "CO-OP", @@ -431,11 +431,11 @@ func TestListServersUseCase_FilterByQuery(t *testing.T) { }), ) - passworded := factories.BuildServer( - factories.WithAddress("7.7.7.7", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Info|ds.Details), - factories.WithInfo(map[string]string{ + passworded := serverfactory.Build( + serverfactory.WithAddress("7.7.7.7", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Info|ds.Details), + serverfactory.WithInfo(map[string]string{ "hostname": "Private Swat4 Server", "hostport": "10480", "gametype": "VIP Escort", diff --git a/internal/core/usecases/probeserver/probeserver_test.go b/internal/core/usecases/probeserver/probeserver_test.go index 8b42542..e5852a3 100644 --- a/internal/core/usecases/probeserver/probeserver_test.go +++ b/internal/core/usecases/probeserver/probeserver_test.go @@ -20,7 +20,7 @@ import ( "github.com/sergeii/swat4master/internal/core/usecases/probeserver" "github.com/sergeii/swat4master/internal/metrics" "github.com/sergeii/swat4master/internal/prober/probers" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -97,7 +97,7 @@ func TestProbeServerUseCase_Success(t *testing.T) { logger := zerolog.Nop() collector := metrics.New() - svr := factories.BuildServer(factories.WithAddress("1.1.1.1", 10480)) + svr := serverfactory.Build(serverfactory.WithAddress("1.1.1.1", 10480)) prb := probe.New(svr.Addr, svr.QueryPort, probe.GoalDetails, 3) probeResult := MockProberProbeResult{Success: true} @@ -153,7 +153,7 @@ func TestProbeServerUseCase_RetryOnFailure(t *testing.T) { logger := zerolog.Nop() collector := metrics.New() - svr := factories.BuildServer(factories.WithAddress("1.1.1.1", 10480)) + svr := serverfactory.Build(serverfactory.WithAddress("1.1.1.1", 10480)) prb := probe.Probe{ Addr: svr.Addr, Port: svr.QueryPort, @@ -210,7 +210,7 @@ func TestProbeServerUseCase_FailOnOutOfRetries(t *testing.T) { logger := zerolog.Nop() collector := metrics.New() - svr := factories.BuildServer(factories.WithAddress("1.1.1.1", 10480)) + svr := serverfactory.Build(serverfactory.WithAddress("1.1.1.1", 10480)) prb := probe.Probe{ Addr: svr.Addr, Port: svr.QueryPort, diff --git a/internal/core/usecases/refreshservers/refreshservers_test.go b/internal/core/usecases/refreshservers/refreshservers_test.go index ef5cb12..950b0a5 100644 --- a/internal/core/usecases/refreshservers/refreshservers_test.go +++ b/internal/core/usecases/refreshservers/refreshservers_test.go @@ -18,7 +18,7 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/core/usecases/refreshservers" "github.com/sergeii/swat4master/internal/metrics" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -49,8 +49,8 @@ func TestRefreshServersUseCase_Success(t *testing.T) { now := time.Now() deadline := now.Add(time.Minute * 10) - svr1 := factories.BuildRandomServer() - svr2 := factories.BuildRandomServer() + svr1 := serverfactory.BuildRandom() + svr2 := serverfactory.BuildRandom() serversToRevive := []server.Server{svr1, svr2} serverRepo := new(MockServerRepository) @@ -139,8 +139,8 @@ func TestRefreshServersUseCase_AddProbeError(t *testing.T) { addProbeErr := errors.New("probe error") - svr1 := factories.BuildRandomServer() - svr2 := factories.BuildRandomServer() + svr1 := serverfactory.BuildRandom() + svr2 := serverfactory.BuildRandom() serversToRevive := []server.Server{svr1, svr2} serverRepo := new(MockServerRepository) diff --git a/internal/core/usecases/removeserver/removeserver_test.go b/internal/core/usecases/removeserver/removeserver_test.go index 0564366..675b70f 100644 --- a/internal/core/usecases/removeserver/removeserver_test.go +++ b/internal/core/usecases/removeserver/removeserver_test.go @@ -14,7 +14,7 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/core/usecases/removeserver" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -55,7 +55,7 @@ func TestRemoveServerUseCase_Success(t *testing.T) { ctx := context.TODO() logger := zerolog.Nop() - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() inst := instance.MustNew("foo", svr.Addr.GetIP(), svr.Addr.Port) serverRepo := new(MockServerRepository) @@ -80,7 +80,7 @@ func TestRemoveServerUseCase_ServerAlreadyDeleted(t *testing.T) { ctx := context.TODO() logger := zerolog.Nop() - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() serverRepo := new(MockServerRepository) serverRepo.On("Get", ctx, svr.Addr).Return(server.Blank, repositories.ErrServerNotFound) @@ -101,7 +101,7 @@ func TestRemoveServerUseCase_InstanceAlreadyDeleted(t *testing.T) { ctx := context.TODO() logger := zerolog.Nop() - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() serverRepo := new(MockServerRepository) serverRepo.On("Get", ctx, svr.Addr).Return(svr, nil) @@ -124,7 +124,7 @@ func TestRemoveServerUseCase_InstanceAddrDoesNotMatch(t *testing.T) { ctx := context.TODO() logger := zerolog.Nop() - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() inst := instance.MustNew("foo", testutils.GenRandomIP(), svr.Addr.Port) serverRepo := new(MockServerRepository) diff --git a/internal/core/usecases/renewserver/renewserver_test.go b/internal/core/usecases/renewserver/renewserver_test.go index 377bcfb..e2ab23d 100644 --- a/internal/core/usecases/renewserver/renewserver_test.go +++ b/internal/core/usecases/renewserver/renewserver_test.go @@ -15,7 +15,7 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/core/usecases/renewserver" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -51,7 +51,7 @@ func TestRenewServerUseCase_Success(t *testing.T) { ctx := context.TODO() clock := clockwork.NewFakeClock() - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() inst := instance.MustNew("foo", svr.Addr.GetIP(), svr.Addr.Port) clock.Advance(time.Second) @@ -80,7 +80,7 @@ func TestRenewServerUseCase_InstanceNotFound(t *testing.T) { ctx := context.TODO() clock := clockwork.NewFakeClock() - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() inst := instance.MustNew("foo", svr.Addr.GetIP(), svr.Addr.Port) serverRepo := new(MockServerRepository) @@ -102,7 +102,7 @@ func TestRenewServerUseCase_ServerNotFound(t *testing.T) { ctx := context.TODO() clock := clockwork.NewFakeClock() - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() inst := instance.MustNew("foo", svr.Addr.GetIP(), svr.Addr.Port) serverRepo := new(MockServerRepository) @@ -124,7 +124,7 @@ func TestRenewServerUseCase_InstanceAddressMismatch(t *testing.T) { ctx := context.TODO() clock := clockwork.NewFakeClock() - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() inst := instance.MustNew("foo", testutils.GenRandomIP(), svr.Addr.Port) serverRepo := new(MockServerRepository) diff --git a/internal/core/usecases/reportserver/reportserver_test.go b/internal/core/usecases/reportserver/reportserver_test.go index 54b4f89..93e41c5 100644 --- a/internal/core/usecases/reportserver/reportserver_test.go +++ b/internal/core/usecases/reportserver/reportserver_test.go @@ -21,7 +21,7 @@ import ( "github.com/sergeii/swat4master/internal/core/usecases/reportserver" "github.com/sergeii/swat4master/internal/metrics" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" "github.com/sergeii/swat4master/internal/validation" ) @@ -212,7 +212,7 @@ func TestReportServerUseCase_ReportExistingServer(t *testing.T) { svrParams := testutils.GenExtraServerParams(map[string]string{"mapname": "A-Bomb Nightclub"}) svrDetails := details.MustNewDetailsFromParams(svrParams, svrPlayers, nil) - svr := factories.BuildRandomServer() + svr := serverfactory.BuildRandom() svr.UpdateDetails(svrDetails, now) svr.UpdateDiscoveryStatus(tt.discoveryStatus) diff --git a/internal/core/usecases/reviveservers/reviveservers_test.go b/internal/core/usecases/reviveservers/reviveservers_test.go index 16d3bf7..4bca276 100644 --- a/internal/core/usecases/reviveservers/reviveservers_test.go +++ b/internal/core/usecases/reviveservers/reviveservers_test.go @@ -18,7 +18,7 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/core/usecases/reviveservers" "github.com/sergeii/swat4master/internal/metrics" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -53,8 +53,8 @@ func TestReviveServersUseCase_Success(t *testing.T) { maxCountdown := now.Add(time.Minute * 5) deadline := now.Add(time.Minute * 10) - svr1 := factories.BuildRandomServer() - svr2 := factories.BuildRandomServer() + svr1 := serverfactory.BuildRandom() + svr2 := serverfactory.BuildRandom() serversToRevive := []server.Server{svr1, svr2} serverRepo := new(MockServerRepository) @@ -155,8 +155,8 @@ func TestReviveServersUseCase_AddProbeError(t *testing.T) { now := time.Now() addProbeErr := errors.New("probe error") - svr1 := factories.BuildRandomServer() - svr2 := factories.BuildRandomServer() + svr1 := serverfactory.BuildRandom() + svr2 := serverfactory.BuildRandom() serversToRevive := []server.Server{svr1, svr2} serverRepo := new(MockServerRepository) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 41adb3a..9c9b6ba 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -34,6 +34,7 @@ type Collector struct { DiscoveryQueueProduced prometheus.Counter DiscoveryQueueConsumed prometheus.Counter DiscoveryQueueExpired prometheus.Counter + DiscoveryQueueErrors prometheus.Counter DiscoveryProbes *prometheus.CounterVec DiscoveryProbeSuccess *prometheus.CounterVec DiscoveryProbeRetries *prometheus.CounterVec @@ -132,6 +133,10 @@ func New() *Collector { Name: "discovery_queue_expired_total", Help: "The total number of expired probes in discovery queue", }), + DiscoveryQueueErrors: promauto.With(registry).NewCounter(prometheus.CounterOpts{ + Name: "discovery_queue_errors_total", + Help: "The total number of errors occurred during discovery queue operations", + }), DiscoveryProbeDurations: promauto.With(registry).NewHistogramVec(prometheus.HistogramOpts{ Name: "discovery_probe_duration_seconds", Help: "Duration of discovery probes", diff --git a/internal/metrics/observers/serverobserver/serverobserver_test.go b/internal/metrics/observers/serverobserver/serverobserver_test.go index 8e8abd1..6ecd270 100644 --- a/internal/metrics/observers/serverobserver/serverobserver_test.go +++ b/internal/metrics/observers/serverobserver/serverobserver_test.go @@ -18,7 +18,7 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/metrics" "github.com/sergeii/swat4master/internal/metrics/observers/serverobserver" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type MockServerRepository struct { @@ -56,23 +56,23 @@ func TestServerObserver_Observe_OK(t *testing.T) { ds.Info: 8, } activeServers := []server.Server{ - factories.BuildServer( - factories.WithRandomAddress(), - factories.WithInfo(map[string]string{ + serverfactory.Build( + serverfactory.WithRandomAddress(), + serverfactory.WithInfo(map[string]string{ "gametype": "VIP Escort", "numplayers": "0", }), ), - factories.BuildServer( - factories.WithRandomAddress(), - factories.WithInfo(map[string]string{ + serverfactory.Build( + serverfactory.WithRandomAddress(), + serverfactory.WithInfo(map[string]string{ "gametype": "VIP Escort", "numplayers": "15", }), ), - factories.BuildServer( - factories.WithRandomAddress(), - factories.WithInfo(map[string]string{ + serverfactory.Build( + serverfactory.WithRandomAddress(), + serverfactory.WithInfo(map[string]string{ "gametype": "CO-OP", "numplayers": "5", }), diff --git a/internal/persistence/memory/probes/probes.go b/internal/persistence/memory/probes/probes.go deleted file mode 100644 index 2da44ca..0000000 --- a/internal/persistence/memory/probes/probes.go +++ /dev/null @@ -1,166 +0,0 @@ -package probes - -import ( - "container/list" - "context" - "errors" - "sync" - "time" - - "github.com/jonboulle/clockwork" - - "github.com/sergeii/swat4master/internal/core/entities/probe" - "github.com/sergeii/swat4master/internal/core/repositories" -) - -type enqueued struct { - probe probe.Probe - before time.Time - after time.Time -} - -type Repository struct { - queue *list.List - length int - clock clockwork.Clock - mutex sync.RWMutex -} - -func New(c clockwork.Clock) *Repository { - repo := &Repository{ - queue: list.New(), - clock: c, - } - return repo -} - -func (r *Repository) Add(_ context.Context, prb probe.Probe) error { - r.enqueue(prb, repositories.NC, repositories.NC) - return nil -} - -func (r *Repository) AddBetween(_ context.Context, prb probe.Probe, after time.Time, before time.Time) error { - r.enqueue(prb, before, after) - return nil -} - -func (r *Repository) Pop(_ context.Context) (probe.Probe, error) { - r.mutex.Lock() - defer r.mutex.Unlock() - passes := r.length - for { - next, err := r.next() - if err == nil { - r.queue.Remove(next) - r.length-- - return next.Value.(enqueued).probe, nil // nolint: forcetypeassert - } - passes-- - if passes > 0 { - continue - } - switch { - case errors.Is(err, repositories.ErrProbeHasExpired): - return probe.Blank, repositories.ErrProbeQueueIsEmpty - default: - return probe.Blank, err - } - } -} - -func (r *Repository) PopAny(_ context.Context) (probe.Probe, error) { - r.mutex.Lock() - defer r.mutex.Unlock() - last := r.queue.Front() - // queue is empty - if last == nil { - return probe.Blank, repositories.ErrProbeQueueIsEmpty - } - r.queue.Remove(last) - r.length-- - return last.Value.(enqueued).probe, nil // nolint: forcetypeassert -} - -func (r *Repository) PopMany(_ context.Context, count int) ([]probe.Probe, int, error) { - r.mutex.Lock() - defer r.mutex.Unlock() - - // queue is empty - if r.length == 0 { - return nil, 0, nil - } - - probes := make([]probe.Probe, 0, count) - seenItems := make([]*list.Element, 0, count) - futureItems := make([]*list.Element, 0) - expiredCount := 0 - - now := r.clock.Now() - - for next := r.queue.Front(); next != nil && len(probes) < count; next = next.Next() { - item := next.Value.(enqueued) // nolint: forcetypeassert - // target's time hasn't come yet - if !item.after.IsZero() && item.after.After(now) { - futureItems = append(futureItems, next) - continue - } - seenItems = append(seenItems, next) - // target's time has not expired, or the expiration time hasn't been set - if item.before.IsZero() || item.before.After(now) { - probes = append(probes, item.probe) - } else { - expiredCount++ - } - } - - // remove expired and obtained targets from the queue - for _, seen := range seenItems { - r.queue.Remove(seen) - r.length-- - } - - // move future targets to the end of the queue - for _, future := range futureItems { - r.queue.MoveToBack(future) - } - - return probes, expiredCount, nil -} - -func (r *Repository) Count(context.Context) (int, error) { - r.mutex.RLock() - defer r.mutex.RUnlock() - return r.length, nil -} - -func (r *Repository) enqueue( - prb probe.Probe, before time.Time, after time.Time, -) { - r.mutex.Lock() - defer r.mutex.Unlock() - item := enqueued{prb, before, after} - r.queue.PushBack(item) - r.length++ -} - -func (r *Repository) next() (*list.Element, error) { - now := r.clock.Now() - next := r.queue.Front() - // queue is empty - if next == nil { - return nil, repositories.ErrProbeQueueIsEmpty - } - item := next.Value.(enqueued) // nolint: forcetypeassert - // the target's time has expired - if !item.before.IsZero() && item.before.Before(now) { - r.queue.Remove(next) - r.length-- - return nil, repositories.ErrProbeHasExpired - } - // the target's time hasn't come yet - if !item.after.IsZero() && item.after.After(now) { - r.queue.MoveToBack(next) - return nil, repositories.ErrProbeIsNotReady - } - return next, nil -} diff --git a/internal/persistence/memory/probes/probes_test.go b/internal/persistence/memory/probes/probes_test.go deleted file mode 100644 index 3bf4e8a..0000000 --- a/internal/persistence/memory/probes/probes_test.go +++ /dev/null @@ -1,556 +0,0 @@ -package probes_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/jonboulle/clockwork" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/sergeii/swat4master/internal/core/entities/addr" - "github.com/sergeii/swat4master/internal/core/entities/probe" - "github.com/sergeii/swat4master/internal/core/repositories" - "github.com/sergeii/swat4master/internal/persistence/memory/probes" -) - -func TestProbesMemoryRepo_Add(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - - err := repo.Add(ctx, probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0)) - assert.NoError(t, err) - cnt, _ := repo.Count(ctx) - assert.Equal(t, 1, cnt) - - err = repo.Add(ctx, probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0)) - assert.NoError(t, err) - cnt, _ = repo.Count(ctx) - assert.Equal(t, 2, cnt) - - p1, err := repo.Pop(ctx) - assert.NoError(t, err) - assert.Equal(t, "1.1.1.1", p1.Addr.GetDottedIP()) - - p2, err := repo.Pop(ctx) - assert.NoError(t, err) - assert.Equal(t, "2.2.2.2", p2.Addr.GetDottedIP()) - - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) -} - -func TestProbesMemoryRepo_AddBetween(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - now := c.Now() - - err := repo.AddBetween( - ctx, - probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*10), - now.Add(time.Millisecond*50), - ) - assert.NoError(t, err) - - cnt, _ := repo.Count(ctx) - assert.Equal(t, 1, cnt) - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - err = repo.AddBetween( - ctx, - probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*10), - now.Add(time.Millisecond*15), - ) - assert.NoError(t, err) - - err = repo.AddBetween( - ctx, - probe.New(addr.MustNewFromDotted("3.3.3.3", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*25), - now.Add(time.Millisecond*50), - ) - assert.NoError(t, err) - - cnt, _ = repo.Count(ctx) - assert.Equal(t, 3, cnt) - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - c.Advance(time.Millisecond * 5) - - // not ready yet - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - c.Advance(time.Millisecond * 15) - - // 1st item is ready - p1, err := repo.Pop(ctx) - assert.NoError(t, err) - assert.Equal(t, "1.1.1.1", p1.Addr.GetDottedIP()) - - // 2nd item has expired - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - // 3rd item is not ready - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - c.Advance(time.Millisecond * 5) - - // 3rd item is now ready - p3, err := repo.Pop(ctx) - assert.NoError(t, err) - assert.Equal(t, "3.3.3.3", p3.Addr.GetDottedIP()) - - // queue is empty now - cnt, _ = repo.Count(ctx) - assert.Equal(t, 0, cnt) - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) -} - -func TestProbesMemoryRepo_AddBetween_After(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - now := c.Now() - - err := repo.AddBetween( - ctx, - probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*50), - repositories.NC, - ) - assert.NoError(t, err) - - cnt, _ := repo.Count(ctx) - assert.Equal(t, 1, cnt) - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - err = repo.AddBetween( - ctx, - probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*100), - repositories.NC, - ) - assert.NoError(t, err) - cnt, _ = repo.Count(ctx) - assert.Equal(t, 2, cnt) - - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - c.Advance(time.Millisecond * 5) - // not ready yet - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - // 1st item is ready - c.Advance(time.Millisecond * 50) - p1, err := repo.Pop(ctx) - assert.NoError(t, err) - assert.Equal(t, "1.1.1.1", p1.Addr.GetDottedIP()) - - // 2nd item still not ready - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeIsNotReady) - - // 2nd item is now ready - c.Advance(time.Millisecond * 50) - p1, err = repo.Pop(ctx) - assert.NoError(t, err) - assert.Equal(t, "2.2.2.2", p1.Addr.GetDottedIP()) - - // queue is empty now - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) -} - -func TestProbesMemoryRepo_AddBetween_AddBefore(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - now := c.Now() - - err := repo.AddBetween( - ctx, - probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0), - repositories.NC, - now.Add(time.Millisecond*50), - ) - assert.NoError(t, err) - - err = repo.AddBetween( - ctx, - probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0), - repositories.NC, - now.Add(time.Millisecond*50), - ) - assert.NoError(t, err) - - cntBeforeSleep, _ := repo.Count(ctx) - assert.Equal(t, 2, cntBeforeSleep) - - c.Advance(time.Millisecond * 10) - - cntAfterSleep, _ := repo.Count(ctx) - assert.Equal(t, 2, cntAfterSleep) - - p1, err := repo.Pop(ctx) - assert.NoError(t, err) - assert.Equal(t, "1.1.1.1", p1.Addr.GetDottedIP()) - - cntAfterPop, _ := repo.Count(ctx) - assert.Equal(t, 1, cntAfterPop) - - c.Advance(time.Millisecond * 41) - - cntAfterPopSleep, _ := repo.Count(ctx) - assert.Equal(t, 1, cntAfterPopSleep) - - // other probe is now expired - _, err = repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) - - cntAfterEmptyPop, _ := repo.Count(ctx) - assert.Equal(t, 0, cntAfterEmptyPop) -} - -func TestProbesMemoryRepo_PopExpired(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - now := c.Now() - - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0), - repositories.NC, - now.Add(-time.Millisecond*50), - ) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0), - repositories.NC, - now.Add(-time.Millisecond*10), - ) - cnt, _ := repo.Count(ctx) - assert.Equal(t, 2, cnt) - - _, err := repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) - cnt, _ = repo.Count(ctx) - assert.Equal(t, 0, cnt) -} - -func TestProbesMemoryRepo_Pop(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - now := c.Now() - - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*75), - repositories.NC, - ) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*50), - repositories.NC, - ) - repo.Add(ctx, probe.New(addr.MustNewFromDotted("3.3.3.3", 10480), 10480, probe.GoalDetails, 0)) // nolint:errcheck - repo.Add(ctx, probe.New(addr.MustNewFromDotted("4.4.4.4", 10480), 10480, probe.GoalDetails, 0)) // nolint:errcheck - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("5.5.5.5", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*100), - repositories.NC, - ) - - popped := make([]string, 0) - started := make(chan struct{}) - ready := make(chan struct{}) - - advanced := make(chan bool) - go func() { - close(started) - for range advanced { - prb, err := repo.Pop(ctx) - if errors.Is(err, repositories.ErrProbeIsNotReady) { - continue - } else if errors.Is(err, repositories.ErrProbeQueueIsEmpty) { - continue - } - popped = append(popped, prb.Addr.GetDottedIP()) - } - close(ready) - }() - - <-started - // advance 100ms in steps - for range 100 { - c.Advance(time.Millisecond * 1) - advanced <- true - } - close(advanced) - <-ready - - assert.Equal(t, []string{"3.3.3.3", "4.4.4.4", "2.2.2.2", "1.1.1.1", "5.5.5.5"}, popped) - - // queue is empty now - _, err := repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) -} - -func TestProbesMemoryRepo_PopAny(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - now := c.Now() - - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*75), - repositories.NC, - ) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*50), - repositories.NC, - ) - repo.Add(ctx, probe.New(addr.MustNewFromDotted("3.3.3.3", 10480), 10480, probe.GoalDetails, 0)) // nolint:errcheck - repo.Add(ctx, probe.New(addr.MustNewFromDotted("4.4.4.4", 10480), 10480, probe.GoalDetails, 0)) // nolint:errcheck - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("5.5.5.5", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*100), - repositories.NC, - ) - - popped := make([]string, 0) - for range 5 { - prb, err := repo.PopAny(ctx) - require.NoError(t, err) - popped = append(popped, prb.Addr.GetDottedIP()) - } - - assert.Equal(t, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4", "5.5.5.5"}, popped) - - // queue is empty now - _, err := repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) -} - -func TestProbesMemoryRepo_PopMany(t *testing.T) { - tests := []struct { - name string - count int - popped []string - remaining []string - expired1 int - expired2 int - expired3 int - }{ - { - "pop nothing", - 0, - []string{}, - []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4", "5.5.5.5"}, - 0, - 1, - 2, - }, - { - "pop just 1 probe", - 1, - []string{"3.3.3.3"}, - []string{"4.4.4.4", "5.5.5.5", "1.1.1.1", "2.2.2.2"}, - 1, - 2, - 0, - }, - { - "pop exactly as there are probes in queue", - 6, - []string{"3.3.3.3", "4.4.4.4", "6.6.6.6"}, - []string{"1.1.1.1", "2.2.2.2", "5.5.5.5"}, - 2, - 0, - 0, - }, - { - "pop exactly as there are available probes in queue", - 3, - []string{"3.3.3.3", "4.4.4.4", "6.6.6.6"}, - []string{"1.1.1.1", "2.2.2.2", "5.5.5.5"}, - 1, - 1, - 0, - }, - { - "pop more probes than in queue", - 10, - []string{"3.3.3.3", "4.4.4.4", "6.6.6.6"}, - []string{"1.1.1.1", "2.2.2.2", "5.5.5.5"}, - 2, - 0, - 0, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.TODO() - - c := clockwork.NewFakeClock() - repo := probes.New(c) - now := c.Now() - - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("7.7.7.7", 10480), 10480, probe.GoalDetails, 0), - repositories.NC, - now.Add(-time.Millisecond*1), - ) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*75), - repositories.NC, - ) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*50), - repositories.NC, - ) - repo.Add(ctx, probe.New( // nolint:errcheck - addr.MustNewFromDotted("3.3.3.3", 10480), - 10480, - probe.GoalDetails, - 0, - )) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("4.4.4.4", 10480), 10480, probe.GoalDetails, 0), - repositories.NC, - now.Add(time.Millisecond*150), - ) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("5.5.5.5", 10480), 10480, probe.GoalDetails, 0), - now.Add(time.Millisecond*100), - repositories.NC, - ) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("6.6.6.6", 10480), 10480, probe.GoalDetails, 0), - repositories.NC, - now.Add(time.Millisecond*10), - ) - repo.AddBetween( // nolint:errcheck - ctx, - probe.New(addr.MustNewFromDotted("8.8.8.8", 10480), 10480, probe.GoalDetails, 0), - repositories.NC, - now.Add(-time.Millisecond*10), - ) - - popped, expired, err := repo.PopMany(ctx, tt.count) - assert.NoError(t, err) - assert.Equal(t, tt.popped, getProbesIPs(popped)) - assert.Equal(t, tt.expired1, expired) - - c.Advance(time.Millisecond * 100) - - remaining, expired, err := repo.PopMany(ctx, 5) - assert.NoError(t, err) - assert.Equal(t, tt.remaining, getProbesIPs(remaining)) - assert.Equal(t, tt.expired2, expired) - - // queue is empty now - maybeMore, expired, _ := repo.PopMany(ctx, 5) - assert.Equal(t, 0, len(maybeMore)) - assert.Equal(t, tt.expired3, expired) - - count, err := repo.Count(ctx) - require.NoError(t, err) - assert.Equal(t, 0, count) - }) - } -} - -func TestProbesMemoryRepo_PopEmpty(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - _, err := repo.Pop(ctx) - assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) -} - -func TestProbesMemoryRepo_PopManyEmpty(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - - popped, expired, err := repo.PopMany(ctx, 5) - assert.NoError(t, err) - assert.Equal(t, 0, len(popped)) - assert.Equal(t, 0, expired) -} - -func TestProbesMemoryRepo_Count(t *testing.T) { - ctx := context.TODO() - c := clockwork.NewFakeClock() - repo := probes.New(c) - - assertCount := func(expected int) { - cnt, err := repo.Count(ctx) - assert.NoError(t, err) - assert.Equal(t, expected, cnt) - } - - assertCount(0) - - t1 := probe.New(addr.MustNewFromDotted("1.1.1.1", 10480), 10480, probe.GoalDetails, 0) - _ = repo.Add(ctx, t1) - assertCount(1) - - t2 := probe.New(addr.MustNewFromDotted("2.2.2.2", 10480), 10480, probe.GoalDetails, 0) - _ = repo.Add(ctx, t2) - assertCount(2) - - _, _ = repo.Pop(ctx) - assertCount(1) - - _, _ = repo.Pop(ctx) - assertCount(0) - - _, _ = repo.Pop(ctx) - assertCount(0) - - t3 := probe.New(addr.MustNewFromDotted("3.3.3.3", 10480), 10480, probe.GoalDetails, 0) - _ = repo.Add(ctx, t3) - assertCount(1) -} - -func getProbesIPs(probes []probe.Probe) []string { - ips := make([]string, 0, len(probes)) - for _, prb := range probes { - ips = append(ips, prb.Addr.GetDottedIP()) - } - return ips -} diff --git a/internal/persistence/redis/probes/probes.go b/internal/persistence/redis/probes/probes.go new file mode 100644 index 0000000..0408b33 --- /dev/null +++ b/internal/persistence/redis/probes/probes.go @@ -0,0 +1,227 @@ +package probes + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/jonboulle/clockwork" + "github.com/redis/go-redis/v9" + + "github.com/sergeii/swat4master/internal/core/entities/probe" + "github.com/sergeii/swat4master/internal/core/repositories" +) + +const ( + queueKey = "probes:queue" + dataKey = "probes:items" +) + +const maxPopAttempts = 5 + +type Repository struct { + client *redis.Client + clock clockwork.Clock +} + +type qItem struct { + Probe probe.Probe `json:"probe"` + Expires time.Time `json:"expires"` +} + +func New(client *redis.Client, c clockwork.Clock) *Repository { + return &Repository{ + client: client, + clock: c, + } +} + +func (r *Repository) Add(ctx context.Context, prb probe.Probe) error { + return r.enqueue(ctx, prb, repositories.NC, repositories.NC) +} + +func (r *Repository) AddBetween(ctx context.Context, prb probe.Probe, after time.Time, before time.Time) error { + return r.enqueue(ctx, prb, after, before) +} + +func (r *Repository) enqueue(ctx context.Context, prb probe.Probe, after time.Time, before time.Time) error { + // ignore items with ready time set after or equal to the expiration time + if !after.IsZero() && !before.IsZero() && (after.After(before) || after.Equal(before)) { + return nil + } + + item, err := json.Marshal(qItem{ + Probe: prb, + Expires: before, + }) + if err != nil { + return fmt.Errorf("failed to marshal probe: %w", err) + } + + itemID := uuid.NewString() + // unless specified, the probe is ready to be processed immediately + itemReadyAt := after + if itemReadyAt.IsZero() { + itemReadyAt = r.clock.Now() + } + + _, err = r.client.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.HSet(ctx, dataKey, itemID, item) + // add the probe to the queue + pipe.ZAdd(ctx, queueKey, redis.Z{ + Score: float64(itemReadyAt.UnixNano()), + Member: itemID, + }) + return nil + }) + if err != nil { + return fmt.Errorf("failed to enqueue probe: %w", err) + } + + return nil +} + +func (r *Repository) Pop(ctx context.Context) (probe.Probe, error) { + probes, _, err := r.PopMany(ctx, 1) + if err != nil { + return probe.Blank, err + } + if len(probes) == 0 { + return probe.Blank, repositories.ErrProbeQueueIsEmpty + } + return probes[0], nil +} + +func (r *Repository) Peek(ctx context.Context) (probe.Probe, error) { + keys, err := r.client.ZRange(ctx, queueKey, 0, 1).Result() + if err != nil { + return probe.Blank, fmt.Errorf("failed to peek probe: %w", err) + } + + if len(keys) == 0 { + return probe.Blank, repositories.ErrProbeQueueIsEmpty + } + + value, err := r.client.HGet(ctx, dataKey, keys[0]).Result() + if err != nil { + return probe.Blank, fmt.Errorf("failed to fetch peeked probe: %w", err) + } + + item, err := asQueueItem(value) + if err != nil { + return probe.Blank, fmt.Errorf("failed to unmarshal probe: %w", err) + } + + return item.Probe, nil +} + +func (r *Repository) PopMany(ctx context.Context, count int) ([]probe.Probe, int, error) { + if count <= 0 { + return nil, 0, nil + } + + expired := 0 + probes := make([]probe.Probe, 0, count) + + // fetch the first n probes from the queue that are ready to be processed + for range maxPopAttempts { + items, err := r.pop(ctx, count) + if errors.Is(err, repositories.ErrProbeQueueIsEmpty) { + break + } + if err != nil { + return nil, 0, fmt.Errorf("failed to pop probes: %w", err) + } + for _, item := range items { + if !item.Expires.IsZero() && item.Expires.Before(r.clock.Now()) { + expired++ + continue + } + probes = append(probes, item.Probe) + } + if len(probes) >= count { + break + } + } + + return probes, expired, nil +} + +func (r *Repository) pop(ctx context.Context, count int) ([]qItem, error) { + // fetch the first n probes from the queue that are ready to be processed + keys, err := r.client.ZRangeArgs( + ctx, + redis.ZRangeArgs{ + Key: queueKey, + ByScore: true, + Start: "-inf", + Stop: strconv.FormatInt(r.clock.Now().UnixNano(), 10), // inclusive + Count: int64(count), + }, + ).Result() + if err != nil { + return nil, fmt.Errorf("failed to fetch probes: %w", err) + } + + // queue is empty + if len(keys) == 0 { + return nil, repositories.ErrProbeQueueIsEmpty + } + + // pop the ready-to-process probes from the items set and the queue atomically + var result *redis.SliceCmd + if _, err = r.client.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.ZRem(ctx, queueKey, asMembers(keys)...) + result = pipe.HMGet(ctx, dataKey, keys...) + pipe.HDel(ctx, dataKey, keys...) + return nil + }); err != nil { + return nil, fmt.Errorf("failed to pop probes: %w", err) + } + + items := make([]qItem, 0, len(keys)) + var item qItem + for _, val := range result.Val() { + if val == nil { + continue + } + if item, err = asQueueItem(val); err != nil { + return nil, fmt.Errorf("failed to unmarshal probe: %w", err) + } + items = append(items, item) + } + + return items, nil +} + +func (r *Repository) Count(ctx context.Context) (int, error) { + count, err := r.client.ZCard(ctx, queueKey).Result() + if err != nil { + return 0, fmt.Errorf("failed to count probes: %w", err) + } + return int(count), nil +} + +func asQueueItem(val interface{}) (qItem, error) { + var item qItem + encoded, ok := val.(string) + if !ok { + return qItem{}, fmt.Errorf("unexpected type %T, %v", val, val) + } + if err := json.Unmarshal([]byte(encoded), &item); err != nil { + return qItem{}, fmt.Errorf("failed to unmarshal probe item: %w", err) + } + return item, nil +} + +func asMembers(keys []string) []interface{} { + members := make([]interface{}, len(keys)) + for i, v := range keys { + members[i] = v + } + return members +} diff --git a/internal/persistence/redis/probes/probes_test.go b/internal/persistence/redis/probes/probes_test.go new file mode 100644 index 0000000..aa81519 --- /dev/null +++ b/internal/persistence/redis/probes/probes_test.go @@ -0,0 +1,1068 @@ +package probes_test + +import ( + "context" + "encoding/json" + "math/rand/v2" + "sync/atomic" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/jonboulle/clockwork" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sergeii/swat4master/internal/core/entities/probe" + "github.com/sergeii/swat4master/internal/core/repositories" + "github.com/sergeii/swat4master/internal/persistence/redis/probes" + "github.com/sergeii/swat4master/internal/testutils/factories/probefactory" + "github.com/sergeii/swat4master/pkg/slice" +) + +type qItem struct { + Probe probe.Probe `json:"probe"` + Expires time.Time `json:"expires"` +} + +type qMember struct { + ID string + Time float64 +} + +type qState struct { + Queue []qMember + QueueMembers map[string]float64 + Items map[string]qItem +} + +func ids(items []qMember) []string { + itemKeys := make([]string, 0, len(items)) + for _, item := range items { + itemKeys = append(itemKeys, item.ID) + } + return itemKeys +} + +func must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} + +func mustNoError(err error) { + if err != nil { + panic(err) + } +} + +func collectQueueState(ctx context.Context, rdb *redis.Client) qState { + zQueueMembers := must(rdb.ZRangeWithScores(ctx, "probes:queue", 0, -1).Result()) + hItems := must(rdb.HGetAll(ctx, "probes:items").Result()) + + queue := make([]qMember, 0, len(zQueueMembers)) + queueMembers := make(map[string]float64) + for _, m := range zQueueMembers { + queue = append(queue, qMember{ID: m.Member.(string), Time: m.Score}) // nolint:forcetypeassert + queueMembers[m.Member.(string)] = m.Score // nolint:forcetypeassert + } + + items := make(map[string]qItem) + for k, v := range hItems { + var item qItem + mustNoError(json.Unmarshal([]byte(v), &item)) + items[k] = item + } + + return qState{ + Queue: queue, + QueueMembers: queueMembers, + Items: items, + } +} + +func makeRedisClient(t *testing.T, mr *miniredis.Miniredis) *redis.Client { + rdb := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + t.Cleanup(func() { + mustNoError(rdb.Close()) + }) + return rdb +} + +func makeRedis(t *testing.T) *redis.Client { + mr := miniredis.RunT(t) + return makeRedisClient(t, mr) +} + +func TestProbesRedisRepo_Add_OK(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given a probe + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + + // When the probe is added using the Add method + err := repo.Add(ctx, prb1) + require.NoError(t, err) + + // Then the probe should be placed in the queue and have no time constraints + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) + itemID := slice.First(ids(state.Queue)) + item := state.Items[itemID] + assert.Equal(t, prb1, item.Probe) + assert.True(t, item.Expires.IsZero()) + assert.Equal(t, float64(now.UnixNano()), state.QueueMembers[itemID]) + + // When the clock is advanced by a small amount of time + c.Advance(time.Millisecond) + // and another probe is added to the repository + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + err = repo.Add(ctx, prb2) + require.NoError(t, err) + + // Then the first probe should still be in the queue and the second probe should be added after it + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 2) + assert.Len(t, state.Items, 2) + // the first probe should still be there and be the first one in the queue + qKeys := ids(state.Queue) + assert.Equal(t, itemID, qKeys[0]) + // the second probe should be the second one in the queue and have a later expiration time + otherItemID := slice.First(qKeys[1:]) + otherItem := state.Items[otherItemID] + assert.Equal(t, state.QueueMembers[otherItemID], float64(now.Add(time.Millisecond).UnixNano())) + assert.Equal(t, prb2, otherItem.Probe) + assert.True(t, otherItem.Expires.IsZero()) +} + +func TestProbesRedisRepo_AddBetween_After(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given a probe with After time constraint + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + // When the probe is added using the AddBetween method + err := repo.AddBetween( + ctx, + prb1, + now.Add(time.Millisecond*50), + repositories.NC, + ) + require.NoError(t, err) + // Then the probe should be placed in the queue with the given time constraints + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) + itemID := slice.First(ids(state.Queue)) + item := state.Items[itemID] + assert.Equal(t, float64(now.Add(time.Millisecond*50).UnixNano()), state.QueueMembers[itemID]) + assert.Equal(t, prb1, item.Probe) + assert.True(t, item.Expires.IsZero()) + + // When more probes are added with After time constraints + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + err = repo.AddBetween( + ctx, + prb2, + now.Add(time.Millisecond*100), + repositories.NC, + ) + require.NoError(t, err) + + prb3 := probefactory.Build(probefactory.WithRandomServerAddress()) + err = repo.AddBetween( + ctx, + prb3, + now.Add(time.Millisecond*10), + repositories.NC, + ) + require.NoError(t, err) + + // Then the probes should be placed in the queue sorted by their readiness + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 3) + assert.Len(t, state.Items, 3) + + qKeys := ids(state.Queue) + assert.Equal(t, prb3, state.Items[qKeys[0]].Probe) + assert.Equal(t, float64(now.Add(time.Millisecond*10).UnixNano()), state.QueueMembers[qKeys[0]]) + assert.Equal(t, state.Items[qKeys[1]].Probe, prb1) + assert.Equal(t, float64(now.Add(time.Millisecond*50).UnixNano()), state.QueueMembers[qKeys[1]]) + assert.Equal(t, state.Items[qKeys[2]].Probe, prb2) + assert.Equal(t, float64(now.Add(time.Millisecond*100).UnixNano()), state.QueueMembers[qKeys[2]]) +} + +func TestProbesRedisRepo_AddBetween_Before(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given a probe with Before time constraint + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + // When the probe is added using the AddBetween method + err := repo.AddBetween( + ctx, + prb1, + repositories.NC, + now.Add(time.Millisecond*50), + ) + require.NoError(t, err) + // Then the probe should be placed in the queue with the given time constraint + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) + itemID := slice.First(ids(state.Queue)) + item := state.Items[itemID] + assert.Equal(t, prb1, item.Probe) + assert.Equal(t, now.Add(time.Millisecond*50).Round(time.Microsecond), item.Expires) + + c.Advance(time.Millisecond) + + // When more probes are added with Before time constraints + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + err = repo.AddBetween( + ctx, + prb2, + repositories.NC, + now.Add(-time.Millisecond*50), + ) + require.NoError(t, err) + + c.Advance(time.Millisecond) + + prb3 := probefactory.Build(probefactory.WithRandomServerAddress()) + err = repo.AddBetween( + ctx, + prb3, + repositories.NC, + now.Add(-time.Second), + ) + require.NoError(t, err) + + // Then the probes should be placed in the expired set sorted by their expiration time + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 3) + assert.Len(t, state.Items, 3) + + qKeys := ids(state.Queue) + assert.Equal(t, prb1, state.Items[qKeys[0]].Probe) + assert.Equal(t, now.Add(time.Millisecond*50).Truncate(time.Microsecond), state.Items[qKeys[0]].Expires) + assert.Equal(t, prb2, state.Items[qKeys[1]].Probe) + assert.Equal(t, now.Add(-time.Millisecond*50).Truncate(time.Microsecond), state.Items[qKeys[1]].Expires) + assert.Equal(t, prb3, state.Items[qKeys[2]].Probe) + assert.Equal(t, now.Add(-time.Second).Truncate(time.Microsecond), state.Items[qKeys[2]].Expires) +} + +func TestProbesRedisRepo_AddBetween_Both(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given a probe with time constraints + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + // When the probe is added using the AddBetween method + err := repo.AddBetween( + ctx, + prb1, + now.Add(time.Millisecond*10), + now.Add(time.Millisecond*49), + ) + require.NoError(t, err) + // Then the probe should be placed in the queue with the given time constraints + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) + itemID := slice.First(ids(state.Queue)) + item := state.Items[itemID] + assert.Equal(t, state.QueueMembers[itemID], float64(now.Add(time.Millisecond*10).UnixNano())) + assert.Equal(t, prb1, item.Probe) + assert.Equal(t, now.Add(time.Millisecond*49).Truncate(time.Microsecond), item.Expires) + + // When more probes are added with time constraints + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + err = repo.AddBetween( + ctx, + prb2, + now.Add(time.Millisecond*15), + now.Add(time.Millisecond*100), + ) + require.NoError(t, err) + + prb3 := probefactory.Build(probefactory.WithRandomServerAddress()) + err = repo.AddBetween( + ctx, + prb3, + now.Add(time.Millisecond*1), + now.Add(time.Millisecond*50), + ) + require.NoError(t, err) + + prb4 := probefactory.Build(probefactory.WithRandomServerAddress()) + err = repo.AddBetween( + ctx, + prb4, + now.Add(-time.Millisecond*600), + now.Add(-time.Millisecond*300), + ) + require.NoError(t, err) + + // Then the probes should be placed in the queue with the given time constraints + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 4) + assert.Len(t, state.Items, 4) + + // the probes should be in the queue in the order of their readiness, + // and they should have the correct expiration times + qKeys := ids(state.Queue) + assert.Equal(t, state.QueueMembers[qKeys[0]], float64(now.Add(-time.Millisecond*600).UnixNano())) + assert.Equal(t, prb4, state.Items[qKeys[0]].Probe) + assert.Equal(t, now.Add(-time.Millisecond*300).Truncate(time.Microsecond), state.Items[qKeys[0]].Expires) + assert.Equal(t, state.QueueMembers[qKeys[1]], float64(now.Add(time.Millisecond*1).UnixNano())) + assert.Equal(t, prb3, state.Items[qKeys[1]].Probe) + assert.Equal(t, now.Add(time.Millisecond*50).Truncate(time.Microsecond), state.Items[qKeys[1]].Expires) + assert.Equal(t, state.QueueMembers[qKeys[2]], float64(now.Add(time.Millisecond*10).UnixNano())) + assert.Equal(t, prb1, state.Items[qKeys[2]].Probe) + assert.Equal(t, now.Add(time.Millisecond*49).Truncate(time.Microsecond), state.Items[qKeys[2]].Expires) + assert.Equal(t, state.QueueMembers[qKeys[3]], float64(now.Add(time.Millisecond*15).UnixNano())) + assert.Equal(t, prb2, state.Items[qKeys[3]].Probe) + assert.Equal(t, now.Add(time.Millisecond*100).Truncate(time.Microsecond), state.Items[qKeys[3]].Expires) +} + +func TestProbesRedisRepo_AddBetween_AfterGreaterThanBefore(t *testing.T) { + tests := []struct { + name string + after func(now time.Time) time.Time + before func(now time.Time) time.Time + }{ + { + name: "after greater than before", + after: func(now time.Time) time.Time { + return now.Add(time.Millisecond * 100) + }, + before: func(now time.Time) time.Time { + return now.Add(time.Millisecond * 50) + }, + }, + { + name: "after equal to before", + after: func(now time.Time) time.Time { + return now.Add(time.Millisecond * 50) + }, + before: func(now time.Time) time.Time { + return now.Add(time.Millisecond * 50) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given a probe + prb := probefactory.Build(probefactory.WithRandomServerAddress()) + // When the probe is added using the AddBetween method + // with the After time constraint greater than the Before time constraint + err := repo.AddBetween(ctx, prb, tt.after(now), tt.before(now)) + // Then the probe should not be added to the queue + require.NoError(t, err) + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 0) + assert.Len(t, state.Items, 0) + }) + } +} + +func TestProbesRedisRepo_Pop_OK(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains a probe with no time constraints + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.Add(ctx, prb1)) + + // And another probe that has expired + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb2, now.Add(-time.Millisecond*100), now.Add(-time.Millisecond*50))) + + // And another probe added slightly later + c.Advance(time.Millisecond) + prb3 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.Add(ctx, prb3)) + + // And another probe set to be ready far in the future + prb4 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb4, now.Add(time.Minute), repositories.NC)) + + // And the queue state should be as expected + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 4) + assert.Len(t, state.Items, 4) + + // When the Pop method is called + got, err := repo.Pop(ctx) + require.NoError(t, err) + // Then the probe with the earliest readiness should be returned + assert.Equal(t, prb1, got) + // And the queue should contain the remaining non-expired probes + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 2) + assert.Len(t, state.Items, 2) + + // When the Pop method is called again + got, err = repo.Pop(ctx) + require.NoError(t, err) + // Then the probe with the next earliest readiness should be returned + assert.Equal(t, prb3, got) + // And the queue should contain the remaining non-expired probes + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) + + // When the Pop method is called again + _, err = repo.Pop(ctx) + // Then it should return an error as the last probe is not yet ready + require.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) + // And the queue should contain the non-ready probe + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) + + // When the time has passed so the last probe is ready to be popped + c.Advance(time.Minute) + got, err = repo.Pop(ctx) + require.NoError(t, err) + // Then the last probe should be returned + assert.Equal(t, prb4, got) + // And the queue should be empty + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 0) + assert.Len(t, state.Items, 0) + + // When the Pop method is called again + _, err = repo.Pop(ctx) + // Then it should return an error as the queue is empty + require.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) +} + +func TestProbesRedisRepo_Pop_Empty(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + // Given an empty repository + repo := probes.New(rdb, c) + + // When the Pop method is called + _, err := repo.Pop(ctx) + // Then it should return an error + assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) +} + +func TestProbesRedisRepo_Pop_Expired(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains expires probes + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + + mustNoError(repo.AddBetween(ctx, prb1, repositories.NC, now.Add(-time.Millisecond*50))) + mustNoError(repo.AddBetween(ctx, prb2, repositories.NC, now.Add(-time.Millisecond))) + + // When the Pop method is called + _, err := repo.Pop(ctx) + // Then it should return the same error as when the queue is empty + assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) +} + +func TestProbesRedisRepo_Pop_NotReady(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains a probe that is not yet ready + prv := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prv, now.Add(time.Millisecond*50), repositories.NC)) + + // When the Pop method is called + _, err := repo.Pop(ctx) + // Then it should return an error indicating that the probe is not ready + assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) +} + +func TestProbesRedisRepo_Peek(t *testing.T) { + tests := []struct { + name string + after func(now time.Time) time.Time + before func(now time.Time) time.Time + }{ + { + name: "no constraints", + after: func(_ time.Time) time.Time { + return repositories.NC + }, + before: func(_ time.Time) time.Time { + return repositories.NC + }, + }, + { + name: "almost ready", + after: func(now time.Time) time.Time { + return now.Add(time.Millisecond) + }, + before: func(_ time.Time) time.Time { + return repositories.NC + }, + }, + { + name: "soon to be ready", + after: func(now time.Time) time.Time { + return now.Add(time.Millisecond * 50) + }, + before: func(_ time.Time) time.Time { + return repositories.NC + }, + }, + { + name: "expired some time ago", + after: func(now time.Time) time.Time { + return now.Add(-time.Second * 600) + }, + before: func(now time.Time) time.Time { + return now.Add(-time.Second * 300) + }, + }, + { + name: "expired just now", + after: func(_ time.Time) time.Time { + return repositories.NC + }, + before: func(now time.Time) time.Time { + return now + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains a probe with various time constraints + prb := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb, tt.after(now), tt.before(now))) + + // And another probe that will be ready far in the future + other := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, other, now.Add(time.Hour*24), repositories.NC)) + + // When the Peek method is called + got, err := repo.Peek(ctx) + // Then the first available probe should be returned + require.NoError(t, err) + assert.Equal(t, prb, got) + // And the probe should still be in the queue + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 2) + assert.Len(t, state.Items, 2) + }) + } +} + +func TestProbesRedisRepo_Peek_Empty(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + + // When the Peek method is called + _, err := repo.Peek(ctx) + // Then it should return an error + assert.ErrorIs(t, err, repositories.ErrProbeQueueIsEmpty) +} + +func TestProbesRedisRepo_PopMany_OK(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains a probe with no time constraints + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.Add(ctx, prb1)) + + // And another probe that has expired + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb2, repositories.NC, now.Add(-time.Millisecond*50))) + + // And another probe added slightly later + c.Advance(time.Millisecond) + prb3 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.Add(ctx, prb3)) + + // And another probe set to be ready far in the future + prb4 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb4, now.Add(time.Minute), repositories.NC)) + + // And the queue state should be as expected + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 4) + assert.Len(t, state.Items, 4) + + // When the PopMany method is called + popped, expired, err := repo.PopMany(ctx, 3) + require.NoError(t, err) + // Then the probes with the earliest readiness should be returned + assert.Equal(t, []probe.Probe{prb1, prb3}, popped) + assert.Equal(t, 1, expired) + + // And the queue should contain the remaining non-expired probes + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) + + // When the PopMany method is called again in the future + c.Advance(time.Minute) + popped, expired, err = repo.PopMany(ctx, 3) + require.NoError(t, err) + // Then the last probe should be returned + assert.Equal(t, []probe.Probe{prb4}, popped) + assert.Equal(t, 0, expired) + // And the queue should be empty + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 0) + assert.Len(t, state.Items, 0) +} + +func TestProbesRedisRepo_PopManyAddRace(t *testing.T) { // nolint:gocognit + ctx := context.TODO() + c := clockwork.NewRealClock() + mr := miniredis.RunT(t) + + type produceable struct { + Probe probe.Probe + Expired bool + } + + testSize := 1000 + + // Given a sizeable number of probes to be added and consumed from the repository + initial := make([]probe.Probe, testSize) + for i := range initial { + initial[i] = probefactory.Build(probefactory.WithRandomServerAddress()) + } + + stop := make(chan struct{}) + todo := make(chan produceable, testSize) + popped := make(chan probe.Probe, testSize) + var expiredCnt int64 + + // And a highly concurrent environment + // where Add and PopMany operations are performed simultaneously + produce := func(repo *probes.Repository, todo <-chan produceable) { + select { + case <-stop: + return + case item := <-todo: + if item.Expired { + expiredSecondsAgo := rand.IntN(31) // nolint:gosec + mustNoError( + repo.AddBetween( + ctx, + item.Probe, + repositories.NC, + c.Now().Add(-time.Millisecond*time.Duration(expiredSecondsAgo)), + ), + ) + } else { + mustNoError(repo.Add(ctx, item.Probe)) + } + } + } + + consume := func(repo *probes.Repository, popped chan<- probe.Probe) { + select { + case <-stop: + return + default: + popCount := rand.IntN(5) // nolint:gosec + readyProbes, expired, err := repo.PopMany(ctx, popCount) + if err != nil { + panic(err) + } + for _, prb := range readyProbes { + popped <- prb + } + atomic.AddInt64(&expiredCnt, int64(expired)) + } + } + + for range 10 { + go func() { + interval := rand.IntN(10) + 1 // nolint:gosec + ticker := time.NewTicker(time.Millisecond * time.Duration(interval)) + defer ticker.Stop() + + rdb := makeRedisClient(t, mr) + repo := probes.New(rdb, c) + + for { + select { + case <-stop: + return + case <-ticker.C: + produce(repo, todo) + } + } + }() + } + + for range 10 { + go func() { + interval := rand.IntN(10) + 1 // nolint:gosec + ticker := time.NewTicker(time.Millisecond * time.Duration(interval)) + defer ticker.Stop() + + rdb := makeRedisClient(t, mr) + repo := probes.New(rdb, c) + + for { + select { + case <-stop: + return + case <-ticker.C: + consume(repo, popped) + } + } + }() + } + + // When many probes are produced and consumed by the repository from multiple concurrent clients + go func(itemsToDo []probe.Probe) { + for idx, prb := range itemsToDo { + todo <- produceable{ + Probe: prb, + Expired: idx%10 == 0, // every 10th probe is expired + } + } + }(initial) + + // And enough time has passed for the producers and consumers to finish + consumed := make([]probe.Probe, 0, testSize) + go func(consumed *[]probe.Probe) { + for { + select { + case <-stop: + return + case prb := <-popped: + *consumed = append(*consumed, prb) + } + } + }(&consumed) + + // The worst case for time to wait is calculated as follows: + // 10 producers producing every 10ms will produce 1000 probes in 1 second + // 10 consumers consuming every 10ms will consume 1000 probes in 1 second as well but with some delay, + // as the queue is not full at the beginning, and the consumers need to wait + // for the producers to produce the last available probe which may take up to 1 second + <-time.After(time.Second * 2) + close(stop) + + // Then the non-expired probes should be consumed and the number of expired probes should be as expected + wantConsumed := make([]probe.Probe, 0, testSize) + wantExpiredCnt := 0 + for i, prb := range initial { + if i%10 == 0 { + wantExpiredCnt++ + } else { + wantConsumed = append(wantConsumed, prb) + } + } + assert.ElementsMatch(t, wantConsumed, consumed) + assert.Equal(t, wantExpiredCnt, int(expiredCnt)) + + // and the queue should be exhausted + rdb := makeRedisClient(t, mr) + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 0) + assert.Len(t, state.Items, 0) +} + +func TestProbesRedisRepo_PopMany_Zero(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains some probes with different time constraints + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.Add(ctx, prb1)) + + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb2, now.Add(time.Millisecond), now.Add(time.Millisecond*50))) + + prb3 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb3, now.Add(time.Millisecond*100), repositories.NC)) + + prb4 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb4, now.Add(-time.Millisecond*50), repositories.NC)) + + prb5 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb5, now.Add(-time.Millisecond*100), now.Add(-time.Millisecond))) + + // When the PopMany method is called with a zero count + popped, expired, err := repo.PopMany(ctx, 0) + require.NoError(t, err) + // Then it should return no probes + assert.Len(t, popped, 0) + // And indicate that 1 probe has expired + assert.Equal(t, 0, expired) + + // And the queue should contain all the probes except the one that has expired + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 5) + assert.Len(t, state.Items, 5) + qKeys := ids(state.Queue) + assert.Equal(t, prb5, state.Items[qKeys[0]].Probe) + assert.Equal(t, prb4, state.Items[qKeys[1]].Probe) + assert.Equal(t, prb1, state.Items[qKeys[2]].Probe) + assert.Equal(t, prb2, state.Items[qKeys[3]].Probe) + assert.Equal(t, prb3, state.Items[qKeys[4]].Probe) +} + +func TestProbesRedisRepo_PopMany_NotReady(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains probes that are not yet ready + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb1, now.Add(time.Millisecond*50), repositories.NC)) + + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb2, now.Add(time.Millisecond*100), repositories.NC)) + + // When the PopMany method is called + popped, expired, err := repo.PopMany(ctx, 3) + require.NoError(t, err) + // Then it should return no probes + assert.Len(t, popped, 0) + assert.Equal(t, 0, expired) + + // And the queue should still contain both non-ready probes + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 2) + assert.Len(t, state.Items, 2) + + // And when the time has passed so the first probe is ready + c.Advance(time.Millisecond * 51) + popped, expired, err = repo.PopMany(ctx, 3) + require.NoError(t, err) + // Then it should return the first probe + assert.Len(t, popped, 1) + assert.Equal(t, prb1, popped[0]) + assert.Equal(t, 0, expired) + // And the queue should contain the remaining non-ready probe + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) + + // And when the time has passed so the second probe is ready + c.Advance(time.Millisecond * 50) + popped, expired, err = repo.PopMany(ctx, 3) + require.NoError(t, err) + // Then it should return the second probe + assert.Len(t, popped, 1) + assert.Equal(t, prb2, popped[0]) + assert.Equal(t, 0, expired) + // And the queue should be empty + state = collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 0) + assert.Len(t, state.Items, 0) +} + +func TestProbesRedisRepo_PopMany_Expired(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains only probes that have expired + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb1, repositories.NC, now.Add(-time.Millisecond*50))) + + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb2, repositories.NC, now.Add(-time.Second))) + + // When the PopMany method is called + popped, expired, err := repo.PopMany(ctx, 3) + require.NoError(t, err) + // Then it should return the expired probe count but no probes + assert.Len(t, popped, 0) + assert.Equal(t, 2, expired) + + // And the queue should be empty + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 0) + assert.Len(t, state.Items, 0) +} + +func TestProbesRedisRepo_PopMany_ExpiredAndNotReady(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + // Given the repository contains probes that both have expired and are not yet ready + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb1, repositories.NC, now.Add(-time.Millisecond*50))) + + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb2, now.Add(time.Millisecond*50), repositories.NC)) + + // When the PopMany method is called + popped, expired, err := repo.PopMany(ctx, 3) + require.NoError(t, err) + // Then it should return the expired probe count but no probes + assert.Len(t, popped, 0) + assert.Equal(t, 1, expired) + + // And the queue should contain the non-ready probe + state := collectQueueState(ctx, rdb) + assert.Len(t, state.Queue, 1) + assert.Len(t, state.Items, 1) +} + +func TestProbesRedisRepo_PopMany_Empty(t *testing.T) { + tests := []struct { + name string + count int + }{ + {"pop nothing", 0}, + {"pop 1 probe", 1}, + {"pop 5 probes", 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + + popped, expired, err := repo.PopMany(ctx, tt.count) + require.NoError(t, err) + assert.Equal(t, 0, len(popped)) + assert.Equal(t, 0, expired) + }) + } +} + +func TestProbesRedisRepo_Count(t *testing.T) { + ctx := context.TODO() + c := clockwork.NewFakeClock() + rdb := makeRedis(t) + + repo := probes.New(rdb, c) + now := c.Now() + + assertCount := func(expected int) { + cnt, err := repo.Count(ctx) + assert.NoError(t, err) + assert.Equal(t, expected, cnt) + } + + // Given no probes in the repository yet + // When the Count method is called + // Then the count should be 0 + assertCount(0) + + // When a probe is added to the repository + prb1 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.Add(ctx, prb1)) + // Then the count should be 1 + assertCount(1) + + // When another probe is added to the repository + prb2 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.Add(ctx, prb2)) + // Then the count should be 2 + assertCount(2) + + // When the repository contains an expired probe + prb3 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb3, repositories.NC, now.Add(-time.Second*600))) + // Then the count should account for the expired probe + assertCount(3) + + // When the repository contains a probe that is not yet ready + prb4 := probefactory.Build(probefactory.WithRandomServerAddress()) + mustNoError(repo.AddBetween(ctx, prb4, now.Add(time.Second*600), now.Add(time.Second*700))) + // Then the count should account for the not ready probe + assertCount(4) + + // When multiple probes are popped from the repository + items, expired, _ := repo.PopMany(ctx, 5) + assert.Len(t, items, 2) + assert.Equal(t, 1, expired) + // Then the count should be decremented by the number of popped and expired probes + assertCount(1) + + // When the time has passed so the last probe is ready to be popped + c.Advance(time.Second * 601) + must(repo.Pop(ctx)) + // Then the count should be 0 + assertCount(0) +} diff --git a/internal/prober/probers/detailsprober/detailsprober_test.go b/internal/prober/probers/detailsprober/detailsprober_test.go index b52774d..337646a 100644 --- a/internal/prober/probers/detailsprober/detailsprober_test.go +++ b/internal/prober/probers/detailsprober/detailsprober_test.go @@ -18,7 +18,7 @@ import ( "github.com/sergeii/swat4master/internal/metrics" "github.com/sergeii/swat4master/internal/prober/probers/detailsprober" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" "github.com/sergeii/swat4master/internal/validation" "github.com/sergeii/swat4master/pkg/gamespy/serverquery/gs1" ) @@ -176,7 +176,7 @@ func TestDetailsProber_HandleSuccess_OK(t *testing.T) { prober := detailsprober.New(validate, clock, collector, &logger) - svr := factories.BuildServer(factories.WithDiscoveryStatus(tt.initStatus)) + svr := serverfactory.Build(serverfactory.WithDiscoveryStatus(tt.initStatus)) params := testutils.GenExtraServerParams(map[string]string{"mapname": "A-Bomb Nightclub"}) det := details.MustNewDetailsFromParams(params, nil, nil) @@ -220,7 +220,7 @@ func TestDetailsProber_HandleRetry_OK(t *testing.T) { prober := detailsprober.New(validate, clock, collector, &logger) - svr := factories.BuildServer(factories.WithDiscoveryStatus(tt.initStatus)) + svr := serverfactory.Build(serverfactory.WithDiscoveryStatus(tt.initStatus)) updatedSvr := prober.HandleRetry(svr) assert.Equal(t, tt.wantStatus, updatedSvr.DiscoveryStatus) @@ -265,7 +265,7 @@ func TestDetailsProber_HandleFailure_OK(t *testing.T) { prober := detailsprober.New(validate, clock, collector, &logger) - svr := factories.BuildServer(factories.WithDiscoveryStatus(tt.initStatus)) + svr := serverfactory.Build(serverfactory.WithDiscoveryStatus(tt.initStatus)) updatedSvr := prober.HandleFailure(svr) assert.Equal(t, tt.wantStatus, updatedSvr.DiscoveryStatus) diff --git a/internal/prober/probers/portprober/portprober.go b/internal/prober/probers/portprober/portprober.go index 19077c3..e47cbb4 100644 --- a/internal/prober/probers/portprober/portprober.go +++ b/internal/prober/probers/portprober/portprober.go @@ -101,14 +101,14 @@ func (p PortProber) Probe( } p.logger.Debug(). - Stringer("addr", svrAddr).Stringer("version", best.Response.Version).Int("Port", best.Port). + Stringer("addr", svrAddr).Stringer("version", best.Response.Version).Int("port", best.Port). Msg("Selected preferred response") det, err := details.NewDetailsFromParams(best.Response.Fields, best.Response.Players, best.Response.Objectives) if err != nil { p.logger.Error(). Err(err). - Stringer("addr", svrAddr).Stringer("version", best.Response.Version).Int("Port", best.Port). + Stringer("addr", svrAddr).Stringer("version", best.Response.Version).Int("port", best.Port). Msg("Unable to parse response") return NoResult, fmt.Errorf("%w: %w", ErrParseFailed, err) } diff --git a/internal/prober/probers/portprober/portprober_test.go b/internal/prober/probers/portprober/portprober_test.go index 880b3cf..bfbc9a6 100644 --- a/internal/prober/probers/portprober/portprober_test.go +++ b/internal/prober/probers/portprober/portprober_test.go @@ -15,7 +15,7 @@ import ( ds "github.com/sergeii/swat4master/internal/core/entities/discovery/status" "github.com/sergeii/swat4master/internal/metrics" "github.com/sergeii/swat4master/internal/prober/probers/portprober" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" "github.com/sergeii/swat4master/internal/validation" "github.com/sergeii/swat4master/pkg/gamespy/serverquery/gs1" ) @@ -346,7 +346,7 @@ func TestPortProber_HandleRetry_OK(t *testing.T) { prober := portprober.New(portprober.Opts{}, validate, clock, collector, &logger) - svr := factories.BuildServer(factories.WithDiscoveryStatus(tt.initStatus)) + svr := serverfactory.Build(serverfactory.WithDiscoveryStatus(tt.initStatus)) updatedSvr := prober.HandleRetry(svr) assert.Equal(t, tt.wantStatus, updatedSvr.DiscoveryStatus) @@ -391,7 +391,7 @@ func TestPortProber_HandleFailure_OK(t *testing.T) { prober := portprober.New(portprober.Opts{}, validate, clock, collector, &logger) - svr := factories.BuildServer(factories.WithDiscoveryStatus(tt.initStatus)) + svr := serverfactory.Build(serverfactory.WithDiscoveryStatus(tt.initStatus)) updatedSvr := prober.HandleFailure(svr) assert.Equal(t, tt.wantStatus, updatedSvr.DiscoveryStatus) diff --git a/internal/prober/proberunner/proberunner.go b/internal/prober/proberunner/proberunner.go index 8944c50..1416727 100644 --- a/internal/prober/proberunner/proberunner.go +++ b/internal/prober/proberunner/proberunner.go @@ -169,7 +169,11 @@ func (r *Runner) schedule(ctx context.Context) { probes, expired, err := r.probeRepo.PopMany(ctx, availability) if err != nil { - r.logger.Warn().Err(err).Int("availability", availability).Msg("Unable to fetch new probes") + r.metrics.DiscoveryQueueErrors.Inc() + r.logger.Warn(). + Err(err). + Int("availability", availability). + Msg("Unable to fetch new probes") return } r.metrics.DiscoveryQueueConsumed.Add(float64(len(probes))) diff --git a/internal/testutils/factories/info.go b/internal/testutils/factories/infofactory/infofactory.go similarity index 80% rename from internal/testutils/factories/info.go rename to internal/testutils/factories/infofactory/infofactory.go index ca3acf3..9100ebc 100644 --- a/internal/testutils/factories/info.go +++ b/internal/testutils/factories/infofactory/infofactory.go @@ -1,13 +1,15 @@ -package factories +package infofactory import ( "github.com/sergeii/swat4master/internal/core/entities/details" "github.com/sergeii/swat4master/pkg/slice" ) -type BuildInfoOption func(map[string]string) +type F map[string]string -func WithFields(extra map[string]string) BuildInfoOption { +type BuildOption func(map[string]string) + +func WithFields(extra F) BuildOption { return func(fields map[string]string) { for k, v := range extra { fields[k] = v @@ -15,7 +17,7 @@ func WithFields(extra map[string]string) BuildInfoOption { } } -func BuildInfo(opts ...BuildInfoOption) details.Info { +func Build(opts ...BuildOption) details.Info { fields := map[string]string{ "hostname": slice.RandomChoice([]string{ "Swat4 Server", @@ -31,6 +33,8 @@ func BuildInfo(opts ...BuildInfoOption) details.Info { "gamever": slice.RandomChoice([]string{"1.0", "1.1"}), "gamevariant": slice.RandomChoice([]string{"SWAT 4", "SEF", "SWAT 4X"}), "gametype": slice.RandomChoice([]string{"VIP Escort", "Rapid Deployment", "Barricaded Suspects", "CO-OP"}), + "numplayers": "0", + "maxplayers": "16", } for _, opt := range opts { diff --git a/internal/testutils/factories/instance.go b/internal/testutils/factories/instancefactory/instancefactory.go similarity index 90% rename from internal/testutils/factories/instance.go rename to internal/testutils/factories/instancefactory/instancefactory.go index 2444c78..add5c9d 100644 --- a/internal/testutils/factories/instance.go +++ b/internal/testutils/factories/instancefactory/instancefactory.go @@ -1,4 +1,4 @@ -package factories +package instancefactory import ( "context" @@ -7,7 +7,7 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" ) -func SaveInstance( +func Save( ctx context.Context, repo repositories.InstanceRepository, ins instance.Instance, diff --git a/internal/testutils/factories/probefactory/probefactory.go b/internal/testutils/factories/probefactory/probefactory.go new file mode 100644 index 0000000..28ffdaa --- /dev/null +++ b/internal/testutils/factories/probefactory/probefactory.go @@ -0,0 +1,57 @@ +package probefactory + +import ( + "github.com/sergeii/swat4master/internal/core/entities/addr" + "github.com/sergeii/swat4master/internal/core/entities/probe" + "github.com/sergeii/swat4master/internal/testutils" + "github.com/sergeii/swat4master/pkg/random" +) + +type BuildParams struct { + IP string + Port int + ProbePort int + Goal probe.Goal + MaxRetries int +} + +type BuildOption func(*BuildParams) + +func WithServerAddress(ip string, port int) BuildOption { + return func(p *BuildParams) { + p.IP = ip + p.Port = port + } +} + +func WithRandomServerAddress() BuildOption { + return func(p *BuildParams) { + randomIP := testutils.GenRandomIP() + randPort := random.RandInt(1, 65534) + p.IP = randomIP.String() + p.Port = randPort + } +} + +func Build(opts ...BuildOption) probe.Probe { + params := BuildParams{ + IP: "1.1.1.1", + Port: 10480, + ProbePort: 10481, + Goal: probe.GoalDetails, + MaxRetries: 0, + } + + for _, opt := range opts { + opt(¶ms) + } + + prb := probe.New( + addr.MustNewFromDotted(params.IP, params.Port), + params.ProbePort, + params.Goal, + params.MaxRetries, + ) + + return prb +} diff --git a/internal/testutils/factories/server.go b/internal/testutils/factories/serverfactory/serverfactory.go similarity index 62% rename from internal/testutils/factories/server.go rename to internal/testutils/factories/serverfactory/serverfactory.go index 106acc0..6bd4530 100644 --- a/internal/testutils/factories/server.go +++ b/internal/testutils/factories/serverfactory/serverfactory.go @@ -1,4 +1,4 @@ -package factories +package serverfactory import ( "context" @@ -13,7 +13,7 @@ import ( "github.com/sergeii/swat4master/pkg/random" ) -type BuildServerParams struct { +type BuildParams struct { IP string Port int QueryPort int @@ -23,23 +23,23 @@ type BuildServerParams struct { Objectives []map[string]string } -type BuildServerOption func(*BuildServerParams) +type BuildOption func(*BuildParams) -func WithAddress(ip string, port int) BuildServerOption { - return func(p *BuildServerParams) { +func WithAddress(ip string, port int) BuildOption { + return func(p *BuildParams) { p.IP = ip p.Port = port } } -func WithQueryPort(queryPort int) BuildServerOption { - return func(p *BuildServerParams) { +func WithQueryPort(queryPort int) BuildOption { + return func(p *BuildParams) { p.QueryPort = queryPort } } -func WithRandomAddress() BuildServerOption { - return func(p *BuildServerParams) { +func WithRandomAddress() BuildOption { + return func(p *BuildParams) { randomIP := testutils.GenRandomIP() randPort := random.RandInt(1, 65534) p.IP = randomIP.String() @@ -48,38 +48,38 @@ func WithRandomAddress() BuildServerOption { } } -func WithDiscoveryStatus(status status.DiscoveryStatus) BuildServerOption { - return func(p *BuildServerParams) { +func WithDiscoveryStatus(status status.DiscoveryStatus) BuildOption { + return func(p *BuildParams) { p.DiscoveryStatus = status } } -func WithInfo(fields map[string]string) BuildServerOption { - return func(p *BuildServerParams) { +func WithInfo(fields map[string]string) BuildOption { + return func(p *BuildParams) { p.Info = fields } } -func WithNoInfo() BuildServerOption { - return func(p *BuildServerParams) { +func WithNoInfo() BuildOption { + return func(p *BuildParams) { p.Info = nil } } -func WithPlayers(players []map[string]string) BuildServerOption { - return func(p *BuildServerParams) { +func WithPlayers(players []map[string]string) BuildOption { + return func(p *BuildParams) { p.Players = players } } -func WithObjectives(objectives []map[string]string) BuildServerOption { - return func(p *BuildServerParams) { +func WithObjectives(objectives []map[string]string) BuildOption { + return func(p *BuildParams) { p.Objectives = objectives } } -func BuildServer(opts ...BuildServerOption) server.Server { - params := BuildServerParams{ +func Build(opts ...BuildOption) server.Server { + params := BuildParams{ IP: "1.1.1.1", Port: 10480, QueryPort: 10481, @@ -108,11 +108,11 @@ func BuildServer(opts ...BuildServerOption) server.Server { return svr } -func BuildRandomServer() server.Server { - return BuildServer(WithRandomAddress()) +func BuildRandom() server.Server { + return Build(WithRandomAddress()) } -func SaveServer( +func Save( ctx context.Context, repo repositories.ServerRepository, svr server.Server, @@ -124,11 +124,11 @@ func SaveServer( return savedSvr } -func CreateServer( +func Create( ctx context.Context, repo repositories.ServerRepository, - opts ...BuildServerOption, + opts ...BuildOption, ) server.Server { - svr := BuildServer(opts...) - return SaveServer(ctx, repo, svr) + svr := Build(opts...) + return Save(ctx, repo, svr) } diff --git a/internal/testutils/fixtures.go b/internal/testutils/fixtures.go index a1fd861..b6e4851 100644 --- a/internal/testutils/fixtures.go +++ b/internal/testutils/fixtures.go @@ -15,7 +15,9 @@ func GenRandomIP() net.IP { if !randIP.IsGlobalUnicast() || randIP.IsPrivate() { continue } - log.Printf("generated random IP %s with %d attempts", randIP, i+1) + if i > 0 { + log.Printf("generated random IP %s with %d attempts", randIP, i+1) + } return randIP } panic("unable to generate random IP") diff --git a/internal/testutils/httpserver.go b/internal/testutils/httpserver.go index 8e75de3..58c96a3 100644 --- a/internal/testutils/httpserver.go +++ b/internal/testutils/httpserver.go @@ -1,11 +1,9 @@ package testutils import ( - "context" "net/http/httptest" "github.com/gin-gonic/gin" - "github.com/rs/zerolog" "go.uber.org/fx" "go.uber.org/fx/fxtest" @@ -13,6 +11,7 @@ import ( "github.com/sergeii/swat4master/cmd/swat4master/config" "github.com/sergeii/swat4master/cmd/swat4master/modules/api" "github.com/sergeii/swat4master/internal/core/repositories" + "github.com/sergeii/swat4master/tests/testapp" ) type TestServerRepositories struct { @@ -31,24 +30,22 @@ func PrepareTestServer(tb fxtest.TB, extra ...fx.Option) (*httptest.Server, func HTTPListenAddr: "localhost:11337", } }), + fx.Provide(testapp.ProvidePersistence), application.Module, api.Module, - fx.Decorate(func() *zerolog.Logger { - logger := zerolog.Nop() - return &logger - }), + fx.Decorate(testapp.NoLogging), fx.NopLogger, fx.Populate(&router), } fxopts = append(fxopts, extra...) app := fxtest.New(tb, fxopts...) - app.RequireStart().RequireStop() + app.RequireStart() ts := httptest.NewServer(router) return ts, func() { - defer app.Stop(context.TODO()) // nolint: errcheck + defer app.RequireStop() // nolint: errcheck defer ts.Close() } } @@ -62,9 +59,6 @@ func PrepareTestServerWithRepos( extra, fx.Populate(&repos.Servers, &repos.Instances, &repos.Probes), ) - ts, cleanup := PrepareTestServer( - tb, - extra..., - ) + ts, cleanup := PrepareTestServer(tb, extra...) return ts, repos, cleanup } diff --git a/pkg/slice/slice.go b/pkg/slice/slice.go index 39b9172..c87099b 100644 --- a/pkg/slice/slice.go +++ b/pkg/slice/slice.go @@ -17,3 +17,10 @@ func RandomChoice[T any](s []T) T { idx := rand.Intn(len(s)) // nolint: gosec // no need for crypto/rand here return s[idx] } + +func First[T any](slice []T) T { + if len(slice) == 0 { + panic("empty slice") + } + return slice[0] +} diff --git a/tests/api/servers_add_test.go b/tests/api/servers_add_test.go index e73c711..251eddb 100644 --- a/tests/api/servers_add_test.go +++ b/tests/api/servers_add_test.go @@ -14,7 +14,7 @@ import ( ds "github.com/sergeii/swat4master/internal/core/entities/discovery/status" "github.com/sergeii/swat4master/internal/core/entities/probe" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type serverAddReqSchema struct { @@ -81,7 +81,7 @@ func TestAPI_AddServer_SubmitNew(t *testing.T) { prbCount, _ := repos.Probes.Count(ctx) assert.Equal(t, 1, prbCount) - addedPrb, err := repos.Probes.PopAny(ctx) + addedPrb, err := repos.Probes.Peek(ctx) require.NoError(t, err) assert.Equal(t, probe.GoalPort, addedPrb.Goal) assert.Equal(t, "1.1.1.1:10480", addedPrb.Addr.String()) @@ -160,12 +160,12 @@ func TestAPI_AddServer_SubmitExisting(t *testing.T) { ts, repos, cancel := testutils.PrepareTestServerWithRepos(t) defer cancel() - factories.CreateServer( + serverfactory.Create( ctx, repos.Servers, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10484), - factories.WithDiscoveryStatus(tt.initStatus), + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10484), + serverfactory.WithDiscoveryStatus(tt.initStatus), ) payload, _ := json.Marshal(serverAddReqSchema{ @@ -189,7 +189,7 @@ func TestAPI_AddServer_SubmitExisting(t *testing.T) { require.NoError(t, err) if tt.queued { assert.Equal(t, 1, prbCount) - addedPrb, err := repos.Probes.PopAny(ctx) + addedPrb, err := repos.Probes.Peek(ctx) require.NoError(t, err) assert.Equal(t, probe.GoalPort, addedPrb.Goal) assert.Equal(t, "1.1.1.1:10480", addedPrb.Addr.String()) @@ -225,13 +225,13 @@ func TestAPI_AddServer_AlreadyDiscovered(t *testing.T) { "suspectswon": "2", } - factories.CreateServer( + serverfactory.Create( ctx, repos.Servers, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10484), - factories.WithDiscoveryStatus(ds.Details), - factories.WithInfo(fields), + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10484), + serverfactory.WithDiscoveryStatus(ds.Details), + serverfactory.WithInfo(fields), ) payload, _ := json.Marshal(serverAddReqSchema{ // nolint: errchkjson diff --git a/tests/api/servers_list_test.go b/tests/api/servers_list_test.go index e582a40..a939a13 100644 --- a/tests/api/servers_list_test.go +++ b/tests/api/servers_list_test.go @@ -17,7 +17,7 @@ import ( ds "github.com/sergeii/swat4master/internal/core/entities/discovery/status" "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type serverListSchema struct { @@ -76,7 +76,7 @@ func TestAPI_ListServers_OK(t *testing.T) { "numrounds": "5", }), time.Now()) outdated.UpdateDiscoveryStatus(ds.Master) - factories.SaveServer(ctx, repos.Servers, outdated) + serverfactory.Save(ctx, repos.Servers, outdated) noStatus := server.MustNew(net.ParseIP("4.4.4.4"), 10480, 10481) noStatus.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -87,7 +87,7 @@ func TestAPI_ListServers_OK(t *testing.T) { "gamevariant": "SWAT 4", "gametype": "Barricaded Suspects", }), time.Now()) - factories.SaveServer(ctx, repos.Servers, noStatus) + serverfactory.Save(ctx, repos.Servers, noStatus) delisted := server.MustNew(net.ParseIP("5.5.5.5"), 10480, 10481) delisted.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -99,7 +99,7 @@ func TestAPI_ListServers_OK(t *testing.T) { "gametype": "CO-OP", }), time.Now()) delisted.UpdateDiscoveryStatus(ds.NoDetails) - factories.SaveServer(ctx, repos.Servers, delisted) + serverfactory.Save(ctx, repos.Servers, delisted) noInfo := server.MustNew(net.ParseIP("6.6.6.6"), 10480, 10481) noInfo.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -111,11 +111,11 @@ func TestAPI_ListServers_OK(t *testing.T) { "gametype": "CO-OP", }), time.Now()) noInfo.UpdateDiscoveryStatus(ds.Master | ds.Details) - factories.SaveServer(ctx, repos.Servers, noInfo) + serverfactory.Save(ctx, repos.Servers, noInfo) noRefresh := server.MustNew(net.ParseIP("7.7.7.7"), 10580, 10581) noRefresh.UpdateDiscoveryStatus(ds.Master | ds.Details | ds.Info) - factories.SaveServer(ctx, repos.Servers, noRefresh) + serverfactory.Save(ctx, repos.Servers, noRefresh) gs1 := server.MustNew(net.ParseIP("1.1.1.1"), 10580, 10581) gs1.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -137,7 +137,7 @@ func TestAPI_ListServers_OK(t *testing.T) { "suspectswon": "2", }), time.Now()) gs1.UpdateDiscoveryStatus(ds.Master | ds.Details | ds.Info) - factories.SaveServer(ctx, repos.Servers, gs1) + serverfactory.Save(ctx, repos.Servers, gs1) gs2 := server.MustNew(net.ParseIP("2.2.2.2"), 10480, 10481) gs2.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -151,7 +151,7 @@ func TestAPI_ListServers_OK(t *testing.T) { "maxplayers": "5", }), time.Now()) gs2.UpdateDiscoveryStatus(ds.Master | ds.Info) - factories.SaveServer(ctx, repos.Servers, gs2) + serverfactory.Save(ctx, repos.Servers, gs2) respJSON := make([]serverListSchema, 0) resp := testutils.DoTestRequest( @@ -399,7 +399,7 @@ func TestAPI_ListServers_Filters(t *testing.T) { "maxplayers": "16", }), time.Now()) vip.UpdateDiscoveryStatus(ds.Master | ds.Info) - factories.SaveServer(ctx, repos.Servers, vip) + serverfactory.Save(ctx, repos.Servers, vip) vip10 := server.MustNew(net.ParseIP("2.2.2.2"), 10480, 10481) vip10.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -414,7 +414,7 @@ func TestAPI_ListServers_Filters(t *testing.T) { "maxplayers": "18", }), time.Now()) vip10.UpdateDiscoveryStatus(ds.Master | ds.Info) - factories.SaveServer(ctx, repos.Servers, vip10) + serverfactory.Save(ctx, repos.Servers, vip10) bs := server.MustNew(net.ParseIP("3.3.3.3"), 10480, 10481) bs.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -429,7 +429,7 @@ func TestAPI_ListServers_Filters(t *testing.T) { "maxplayers": "16", }), time.Now()) bs.UpdateDiscoveryStatus(ds.Master | ds.Details | ds.Info) - factories.SaveServer(ctx, repos.Servers, bs) + serverfactory.Save(ctx, repos.Servers, bs) coop := server.MustNew(net.ParseIP("4.4.4.4"), 10480, 10481) coop.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -444,7 +444,7 @@ func TestAPI_ListServers_Filters(t *testing.T) { "maxplayers": "5", }), time.Now()) coop.UpdateDiscoveryStatus(ds.Details | ds.Info) - factories.SaveServer(ctx, repos.Servers, coop) + serverfactory.Save(ctx, repos.Servers, coop) sg := server.MustNew(net.ParseIP("5.5.5.5"), 10480, 10481) sg.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -459,7 +459,7 @@ func TestAPI_ListServers_Filters(t *testing.T) { "maxplayers": "16", }), time.Now()) sg.UpdateDiscoveryStatus(ds.Master | ds.Info | ds.NoDetails) - factories.SaveServer(ctx, repos.Servers, sg) + serverfactory.Save(ctx, repos.Servers, sg) coopx := server.MustNew(net.ParseIP("6.6.6.6"), 10480, 10481) coopx.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -474,7 +474,7 @@ func TestAPI_ListServers_Filters(t *testing.T) { "maxplayers": "10", }), time.Now()) coopx.UpdateDiscoveryStatus(ds.Master | ds.Info) - factories.SaveServer(ctx, repos.Servers, coopx) + serverfactory.Save(ctx, repos.Servers, coopx) passworded := server.MustNew(net.ParseIP("7.7.7.7"), 10480, 10481) passworded.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ @@ -489,7 +489,7 @@ func TestAPI_ListServers_Filters(t *testing.T) { "maxplayers": "16", }), time.Now()) passworded.UpdateDiscoveryStatus(ds.Details | ds.Info) - factories.SaveServer(ctx, repos.Servers, passworded) + serverfactory.Save(ctx, repos.Servers, passworded) respJSON := make([]serverListSchema, 0) uri := "/api/servers" diff --git a/tests/api/servers_view_test.go b/tests/api/servers_view_test.go index 04d1720..f6b4e96 100644 --- a/tests/api/servers_view_test.go +++ b/tests/api/servers_view_test.go @@ -9,7 +9,7 @@ import ( ds "github.com/sergeii/swat4master/internal/core/entities/discovery/status" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" ) type serverDetailInfoSchema struct { @@ -127,14 +127,14 @@ func TestAPI_ViewServer_OK(t *testing.T) { ts, repos, cancel := testutils.PrepareTestServerWithRepos(t) defer cancel() - factories.CreateServer( + serverfactory.Create( ctx, repos.Servers, - factories.WithAddress("1.1.1.1", 10580), - factories.WithQueryPort(10581), - factories.WithDiscoveryStatus(ds.Master|ds.Details|ds.Info), - factories.WithInfo(fields), - factories.WithPlayers(players), + serverfactory.WithAddress("1.1.1.1", 10580), + serverfactory.WithQueryPort(10581), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Details|ds.Info), + serverfactory.WithInfo(fields), + serverfactory.WithPlayers(players), ) obj := serverDetailSchema{} @@ -249,15 +249,15 @@ func TestAPI_ViewServer_Coop_OK(t *testing.T) { ts, repos, cancel := testutils.PrepareTestServerWithRepos(t) defer cancel() - factories.CreateServer( + serverfactory.Create( ctx, repos.Servers, - factories.WithAddress("1.1.1.1", 10880), - factories.WithQueryPort(10881), - factories.WithDiscoveryStatus(ds.Details|ds.Info), - factories.WithInfo(fields), - factories.WithPlayers(players), - factories.WithObjectives(objectives), + serverfactory.WithAddress("1.1.1.1", 10880), + serverfactory.WithQueryPort(10881), + serverfactory.WithDiscoveryStatus(ds.Details|ds.Info), + serverfactory.WithInfo(fields), + serverfactory.WithPlayers(players), + serverfactory.WithObjectives(objectives), ) obj := serverDetailSchema{} @@ -326,13 +326,13 @@ func TestAPI_ViewServer_MinimalInfo_OK(t *testing.T) { ts, repos, cancel := testutils.PrepareTestServerWithRepos(t) defer cancel() - factories.CreateServer( + serverfactory.Create( ctx, repos.Servers, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Details|ds.Info), - factories.WithInfo(fields), + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Details|ds.Info), + serverfactory.WithInfo(fields), ) obj := serverDetailSchema{} @@ -365,13 +365,13 @@ func TestAPI_ViewServer_NoInfo_OK(t *testing.T) { ts, repos, cancel := testutils.PrepareTestServerWithRepos(t) defer cancel() - factories.CreateServer( + serverfactory.Create( ctx, repos.Servers, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Details|ds.Info), - factories.WithNoInfo(), + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Details|ds.Info), + serverfactory.WithNoInfo(), ) obj := serverDetailSchema{} @@ -422,7 +422,7 @@ func TestAPI_ViewServer_NotFound(t *testing.T) { ts, repos, cancel := testutils.PrepareTestServerWithRepos(t) defer cancel() - factories.CreateServer(ctx, repos.Servers, factories.WithDiscoveryStatus(ds.Details)) + serverfactory.Create(ctx, repos.Servers, serverfactory.WithDiscoveryStatus(ds.Details)) testPath := "/api/servers/" + tt.address @@ -534,7 +534,7 @@ func TestAPI_ViewServer_ValidateAddress(t *testing.T) { ts, repos, cancel := testutils.PrepareTestServerWithRepos(t) defer cancel() - factories.CreateServer(ctx, repos.Servers, factories.WithDiscoveryStatus(ds.Details)) + serverfactory.Create(ctx, repos.Servers, serverfactory.WithDiscoveryStatus(ds.Details)) obj := serverDetailSchema{} resp := testutils.DoTestRequest( @@ -583,13 +583,13 @@ func TestAPI_ViewServer_ValidateStatus(t *testing.T) { "gamevariant": "SWAT 4", "gametype": "VIP Escort", } - factories.CreateServer( + serverfactory.Create( ctx, repos.Servers, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(tt.status), - factories.WithInfo(fields), + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(tt.status), + serverfactory.WithInfo(fields), ) if tt.want { diff --git a/tests/modules/browser_test.go b/tests/modules/browser_test.go index 2aa8ea8..e40ff2c 100644 --- a/tests/modules/browser_test.go +++ b/tests/modules/browser_test.go @@ -21,14 +21,16 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/metrics" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" "github.com/sergeii/swat4master/pkg/binutils" gscrypt "github.com/sergeii/swat4master/pkg/gamespy/crypt" "github.com/sergeii/swat4master/pkg/random" + "github.com/sergeii/swat4master/tests/testapp" ) func makeAppWithBrowser(extra ...fx.Option) (*fx.App, func()) { fxopts := []fx.Option{ + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -147,13 +149,13 @@ func TestBrowser_Filters(t *testing.T) { defer cancel() app.Start(ctx) // nolint: errcheck - factories.CreateServer( + serverfactory.Create( ctx, serverRepo, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info), - factories.WithInfo(map[string]string{ + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info), + serverfactory.WithInfo(map[string]string{ "hostname": "Swat4 Server", "gamever": "1.1", "gametype": "VIP Escort", @@ -164,13 +166,13 @@ func TestBrowser_Filters(t *testing.T) { }), ) - factories.CreateServer( + serverfactory.Create( ctx, serverRepo, - factories.WithAddress("2.2.2.2", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info), - factories.WithInfo(map[string]string{ + serverfactory.WithAddress("2.2.2.2", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info), + serverfactory.WithInfo(map[string]string{ "hostname": "Another Swat4 Server", "gamever": "1.0", "gametype": "VIP Escort", @@ -181,13 +183,13 @@ func TestBrowser_Filters(t *testing.T) { }), ) - factories.CreateServer( + serverfactory.Create( ctx, serverRepo, - factories.WithAddress("3.3.3.3", 10580), - factories.WithQueryPort(10584), - factories.WithDiscoveryStatus(ds.Master|ds.Info), - factories.WithInfo(map[string]string{ + serverfactory.WithAddress("3.3.3.3", 10580), + serverfactory.WithQueryPort(10584), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info), + serverfactory.WithInfo(map[string]string{ "hostname": "New Swat4 Server", "gamever": "1.0", "gametype": "Barricaded Suspects", @@ -220,13 +222,13 @@ func TestBrowser_ParseResponse(t *testing.T) { defer cancel() app.Start(context.TODO()) // nolint: errcheck - factories.CreateServer( + serverfactory.Create( ctx, repo, - factories.WithAddress("20.20.20.20", 10580), - factories.WithQueryPort(10581), - factories.WithDiscoveryStatus(ds.Master), - factories.WithInfo(map[string]string{ + serverfactory.WithAddress("20.20.20.20", 10580), + serverfactory.WithQueryPort(10581), + serverfactory.WithDiscoveryStatus(ds.Master), + serverfactory.WithInfo(map[string]string{ "hostname": "Swat4 Server", "hostport": "10580", "mapname": "A-Bomb Nightclub", @@ -236,13 +238,13 @@ func TestBrowser_ParseResponse(t *testing.T) { }), ) - factories.CreateServer( + serverfactory.Create( ctx, repo, - factories.WithAddress("30.30.30.30", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master), - factories.WithInfo(map[string]string{ + serverfactory.WithAddress("30.30.30.30", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master), + serverfactory.WithInfo(map[string]string{ "hostname": "Another Swat4 Server", "hostport": "10480", "mapname": "A-Bomb Nightclub", @@ -457,13 +459,13 @@ func TestBrowser_ValidateRequest(t *testing.T) { defer cancel() app.Start(ctx) // nolint: errcheck - factories.CreateServer( + serverfactory.Create( ctx, serverRepo, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info), - factories.WithInfo(map[string]string{ + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info), + serverfactory.WithInfo(map[string]string{ "hostname": "Swat4 Server", "gamever": "1.1", "gametype": "VIP Escort", @@ -546,13 +548,13 @@ func TestBrowser_IgnoreInvalidPayload(t *testing.T) { defer cancel() app.Start(ctx) // nolint: errcheck - factories.CreateServer( + serverfactory.Create( ctx, serverRepo, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info), - factories.WithInfo(map[string]string{ + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info), + serverfactory.WithInfo(map[string]string{ "hostname": "Swat4 Server", "gamever": "1.1", "gametype": "VIP Escort", diff --git a/tests/modules/cleaner_test.go b/tests/modules/cleaner_test.go index 452290d..936606a 100644 --- a/tests/modules/cleaner_test.go +++ b/tests/modules/cleaner_test.go @@ -16,11 +16,14 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/instance" "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/metrics" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/instancefactory" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" + "github.com/sergeii/swat4master/tests/testapp" ) func makeAppWithCleaner(extra ...fx.Option) (*fx.App, func()) { fxopts := []fx.Option{ + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -55,27 +58,27 @@ func TestCleaner_OK(t *testing.T) { ins2 := instance.MustNew("bar", net.ParseIP("3.3.3.3"), 10480) ins4 := instance.MustNew("baz", net.ParseIP("4.4.4.4"), 10480) - factories.SaveInstance(ctx, instanceRepo, ins1) - factories.SaveInstance(ctx, instanceRepo, ins2) - factories.SaveInstance(ctx, instanceRepo, ins4) + instancefactory.Save(ctx, instanceRepo, ins1) + instancefactory.Save(ctx, instanceRepo, ins2) + instancefactory.Save(ctx, instanceRepo, ins4) - gs1 := factories.CreateServer( + gs1 := serverfactory.Create( ctx, serverRepo, - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), ) - factories.CreateServer( + serverfactory.Create( ctx, serverRepo, - factories.WithAddress("2.2.2.2", 10480), - factories.WithQueryPort(10481), + serverfactory.WithAddress("2.2.2.2", 10480), + serverfactory.WithQueryPort(10481), ) - factories.CreateServer( + serverfactory.Create( ctx, serverRepo, - factories.WithAddress("3.3.3.3", 10480), - factories.WithQueryPort(10481), + serverfactory.WithAddress("3.3.3.3", 10480), + serverfactory.WithQueryPort(10481), ) // wait for cleaner to run some cycles @@ -86,15 +89,15 @@ func TestCleaner_OK(t *testing.T) { serverRepo.Update(ctx, gs1, repositories.ServerOnConflictIgnore) // nolint: errcheck // add a new server with an instance, it should not be cleaned right away - gs5 := factories.CreateServer( + gs5 := serverfactory.Create( ctx, serverRepo, - factories.WithAddress("5.5.5.5", 10480), - factories.WithQueryPort(10481), + serverfactory.WithAddress("5.5.5.5", 10480), + serverfactory.WithQueryPort(10481), ) ins5 := instance.MustNew("qux", net.ParseIP("5.5.5.5"), 10480) - factories.SaveInstance(ctx, instanceRepo, ins5) + instancefactory.Save(ctx, instanceRepo, ins5) // wait for cleaner to clean servers 2 and 3 <-time.After(time.Millisecond * 150) diff --git a/tests/modules/exporter_test.go b/tests/modules/exporter_test.go index 392c27e..41f0a5a 100644 --- a/tests/modules/exporter_test.go +++ b/tests/modules/exporter_test.go @@ -24,14 +24,15 @@ import ( "github.com/sergeii/swat4master/cmd/swat4master/modules/prober" "github.com/sergeii/swat4master/cmd/swat4master/modules/reporter" "github.com/sergeii/swat4master/internal/core/entities/addr" - "github.com/sergeii/swat4master/internal/core/entities/details" ds "github.com/sergeii/swat4master/internal/core/entities/discovery/status" "github.com/sergeii/swat4master/internal/core/entities/instance" "github.com/sergeii/swat4master/internal/core/entities/probe" "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/testutils" + "github.com/sergeii/swat4master/internal/testutils/factories/infofactory" "github.com/sergeii/swat4master/pkg/gamespy/serverquery/gs1" + "github.com/sergeii/swat4master/tests/testapp" ) func sendUDP(address string, req []byte) { @@ -53,6 +54,7 @@ func getMetrics(t *testing.T) map[string]*dto.MetricFamily { func TestExporter_MasterMetrics(t *testing.T) { app := fx.New( + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -136,6 +138,7 @@ func TestExporter_ServerMetrics(t *testing.T) { var repo repositories.ServerRepository app := fx.New( + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -156,53 +159,59 @@ func TestExporter_ServerMetrics(t *testing.T) { }() svr1 := server.MustNew(net.ParseIP("1.1.1.1"), 10480, 10481) - svr1.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ - "hostname": "Swat4 Server", - "hostport": "10480", - "mapname": "A-Bomb Nightclub", - "gamever": "1.1", - "gamevariant": "SWAT 4", - "gametype": "VIP Escort", - }), time.Now()) + svr1.UpdateInfo( + infofactory.Build( + infofactory.WithFields(infofactory.F{ + "gametype": "VIP Escort", + }, + ), + ), + time.Now(), + ) svr1.UpdateDiscoveryStatus(ds.Master | ds.Info) svr2 := server.MustNew(net.ParseIP("2.2.2.2"), 10480, 10481) - svr2.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ - "hostname": "Another Swat4 Server", - "hostport": "10480", - "mapname": "A-Bomb Nightclub", - "gamever": "1.0", - "gamevariant": "SWAT 4", - "gametype": "Barricaded Suspects", - "numplayers": "12", - "maxplayers": "16", - }), time.Now()) + svr2.UpdateInfo( + infofactory.Build( + infofactory.WithFields(infofactory.F{ + "gametype": "Barricaded Suspects", + "numplayers": "12", + "maxplayers": "16", + }, + ), + ), + time.Now(), + ) svr2.UpdateDiscoveryStatus(ds.Details | ds.Info) svr3 := server.MustNew(net.ParseIP("3.3.3.3"), 10480, 10481) - svr3.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ - "hostname": "Awesome Swat4 Server", - "hostport": "10480", - "mapname": "A-Bomb Nightclub", - "gamever": "1.0", - "gamevariant": "SWAT 4X", - "gametype": "Smash And Grab", - "numplayers": "1", - "maxplayers": "10", - }), time.Now()) + svr3.UpdateInfo( + infofactory.Build( + infofactory.WithFields( + infofactory.F{ + "gametype": "Smash And Grab", + "numplayers": "1", + "maxplayers": "10", + }, + ), + ), + time.Now(), + ) svr3.UpdateDiscoveryStatus(ds.Master | ds.Details | ds.Info) svr4 := server.MustNew(net.ParseIP("4.4.4.4"), 10480, 10481) - svr4.UpdateInfo(details.MustNewInfoFromParams(map[string]string{ - "hostname": "Other Server", - "hostport": "10480", - "mapname": "A-Bomb Nightclub", - "gamever": "1.0", - "gamevariant": "SWAT 4", - "gametype": "VIP Escort", - "numplayers": "14", - "maxplayers": "16", - }), time.Now()) + svr4.UpdateInfo( + infofactory.Build( + infofactory.WithFields( + infofactory.F{ + "gametype": "VIP Escort", + "numplayers": "14", + "maxplayers": "16", + }, + ), + ), + time.Now(), + ) svr4.UpdateDiscoveryStatus(ds.NoDetails) svr1, _ = repo.Add(ctx, svr1, repositories.ServerOnConflictIgnore) @@ -255,6 +264,7 @@ func TestExporter_ReposMetrics(t *testing.T) { var probesRepo repositories.ProbeRepository app := fx.New( + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -310,6 +320,7 @@ func TestExporter_CleanerMetrics(t *testing.T) { var repo repositories.ServerRepository app := fx.New( + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -359,6 +370,7 @@ func TestExporter_ProberMetrics(t *testing.T) { var probeRepo repositories.ProbeRepository app := fx.New( + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ diff --git a/tests/modules/observer_test.go b/tests/modules/observer_test.go index 14505fb..0a7612a 100644 --- a/tests/modules/observer_test.go +++ b/tests/modules/observer_test.go @@ -16,6 +16,7 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/metrics" + "github.com/sergeii/swat4master/tests/testapp" ) func TestObserver_Run(t *testing.T) { @@ -26,6 +27,7 @@ func TestObserver_Run(t *testing.T) { var collector *metrics.Collector app := fx.New( + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ diff --git a/tests/modules/prober_test.go b/tests/modules/prober_test.go index eb3c3e8..f24ed0f 100644 --- a/tests/modules/prober_test.go +++ b/tests/modules/prober_test.go @@ -21,8 +21,9 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/probe" "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/core/repositories" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" "github.com/sergeii/swat4master/pkg/gamespy/serverquery/gs1" + "github.com/sergeii/swat4master/tests/testapp" ) func TestProber_Run(t *testing.T) { @@ -33,6 +34,7 @@ func TestProber_Run(t *testing.T) { defer cancel() app := fx.New( + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -143,7 +145,7 @@ func TestProber_Run(t *testing.T) { svr3.UpdateInfo(info, time.Now()) svr3.UpdateDiscoveryStatus(ds.Master | ds.Port) - svr4 := factories.BuildRandomServer() + svr4 := serverfactory.BuildRandom() svr4.UpdateInfo(info, time.Now()) svr4.UpdateDiscoveryStatus(ds.Master) @@ -196,7 +198,7 @@ func TestProber_Run(t *testing.T) { probeCount, _ := probeRepo.Count(ctx) require.Equal(t, 1, probeCount) - retryProbe, _ := probeRepo.PopAny(ctx) + retryProbe, _ := probeRepo.Peek(ctx) assert.Equal(t, svr3.Addr, retryProbe.Addr) assert.Equal(t, svr3.QueryPort, retryProbe.Port) assert.Equal(t, probe.GoalDetails, retryProbe.Goal) diff --git a/tests/modules/refresher_test.go b/tests/modules/refresher_test.go index b081c84..dd53995 100644 --- a/tests/modules/refresher_test.go +++ b/tests/modules/refresher_test.go @@ -18,11 +18,13 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/metrics" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" + "github.com/sergeii/swat4master/tests/testapp" ) func makeAppWithRefresher(extra ...fx.Option) (*fx.App, func()) { fxopts := []fx.Option{ + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -81,40 +83,40 @@ func TestRefresher_OK(t *testing.T) { ctx, cancel := context.WithCancel(context.TODO()) defer cancel() - gs1 := factories.BuildServer( - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master), + gs1 := serverfactory.Build( + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master), ) - gs2 := factories.BuildServer( - factories.WithAddress("2.2.2.2", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Port), + gs2 := serverfactory.Build( + serverfactory.WithAddress("2.2.2.2", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Port), ) - gs3 := factories.BuildServer( - factories.WithAddress("3.3.3.3", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Details|ds.Port), + gs3 := serverfactory.Build( + serverfactory.WithAddress("3.3.3.3", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Details|ds.Port), ) - gs4 := factories.BuildServer( - factories.WithAddress("5.5.5.5", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.DetailsRetry), + gs4 := serverfactory.Build( + serverfactory.WithAddress("5.5.5.5", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.DetailsRetry), ) - gs5 := factories.BuildServer( - factories.WithAddress("6.6.6.6", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Port|ds.Details|ds.DetailsRetry), + gs5 := serverfactory.Build( + serverfactory.WithAddress("6.6.6.6", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Port|ds.Details|ds.DetailsRetry), ) - gs6 := factories.BuildServer( - factories.WithAddress("7.7.7.7", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info|ds.Details), + gs6 := serverfactory.Build( + serverfactory.WithAddress("7.7.7.7", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info|ds.Details), ) - gs7 := factories.BuildServer( - factories.WithAddress("9.9.9.9", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Port|ds.PortRetry), + gs7 := serverfactory.Build( + serverfactory.WithAddress("9.9.9.9", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Port|ds.PortRetry), ) app, cancel := makeAppWithRefresher( @@ -124,7 +126,7 @@ func TestRefresher_OK(t *testing.T) { app.Start(ctx) // nolint: errcheck for _, gs := range []server.Server{gs1, gs2, gs3, gs4, gs5, gs6, gs7} { - factories.SaveServer(ctx, serverRepo, gs) + serverfactory.Save(ctx, serverRepo, gs) } // let refresher run a cycle diff --git a/tests/modules/reporter_test.go b/tests/modules/reporter_test.go index cad7498..12f6216 100644 --- a/tests/modules/reporter_test.go +++ b/tests/modules/reporter_test.go @@ -26,11 +26,14 @@ import ( "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/metrics" "github.com/sergeii/swat4master/internal/testutils" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" + "github.com/sergeii/swat4master/pkg/slice" + "github.com/sergeii/swat4master/tests/testapp" ) func makeAppWithReporter(extra ...fx.Option) (*fx.App, func()) { fxopts := []fx.Option{ + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -205,7 +208,7 @@ func TestReporter_Heartbeat_ServerIsAddedAndThenUpdated(t *testing.T) { assert.Equal(t, "127.0.0.1:10480", inst.Addr.String()) // probe is added to discover the server's port - prb, err := probeRepo.PopAny(ctx) + prb, err := probeRepo.Peek(ctx) assert.NoError(t, err) assert.Equal(t, "127.0.0.1:10480", prb.Addr.String()) assert.Equal(t, 10480, prb.Port) @@ -370,7 +373,8 @@ func TestReporter_Heartbeat_ServerPortIsDiscovered(t *testing.T) { if tt.wantDiscovered { assert.Equal(t, 1, probeCount) - prb, err := probeRepo.Pop(ctx) + probes, _, err := probeRepo.PopMany(ctx, 1) + prb := slice.First(probes) require.NoError(t, err) assert.Equal(t, probe.GoalPort, prb.Goal) assert.Equal(t, "127.0.0.1:10480", prb.Addr.String()) @@ -521,7 +525,7 @@ func TestReporter_Heartbeat_ServerRemovalIsValidated(t *testing.T) { client := testutils.NewUDPClient("127.0.0.1:33811", 1024, time.Millisecond*10) - svr := factories.BuildServer(factories.WithAddress(tt.ipaddr, 10480), factories.WithQueryPort(10484)) + svr := serverfactory.Build(serverfactory.WithAddress(tt.ipaddr, 10480), serverfactory.WithQueryPort(10484)) inst := instance.MustNew(string([]byte{0xfe, 0xed, 0xf0, 0x0d}), svr.Addr.GetIP(), svr.Addr.Port) serverRepo.Add(ctx, svr, repositories.ServerOnConflictIgnore) // nolint: errcheck instanceRepo.Add(ctx, inst) // nolint: errcheck diff --git a/tests/modules/reviver_test.go b/tests/modules/reviver_test.go index f3f751a..0d3e158 100644 --- a/tests/modules/reviver_test.go +++ b/tests/modules/reviver_test.go @@ -18,11 +18,13 @@ import ( "github.com/sergeii/swat4master/internal/core/entities/server" "github.com/sergeii/swat4master/internal/core/repositories" "github.com/sergeii/swat4master/internal/metrics" - "github.com/sergeii/swat4master/internal/testutils/factories" + "github.com/sergeii/swat4master/internal/testutils/factories/serverfactory" + "github.com/sergeii/swat4master/tests/testapp" ) func makeAppWithReviver(extra ...fx.Option) (*fx.App, func()) { fxopts := []fx.Option{ + fx.Provide(testapp.ProvidePersistence), application.Module, fx.Provide(func() config.Config { return config.Config{ @@ -84,35 +86,35 @@ func TestReviver_OK(t *testing.T) { ctx, cancel := context.WithCancel(context.TODO()) defer cancel() - gs1 := factories.BuildServer( - factories.WithAddress("1.1.1.1", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master), + gs1 := serverfactory.Build( + serverfactory.WithAddress("1.1.1.1", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master), ) - gs2 := factories.BuildServer( - factories.WithAddress("2.2.2.2", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Port), + gs2 := serverfactory.Build( + serverfactory.WithAddress("2.2.2.2", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Port), ) - gs3 := factories.BuildServer( - factories.WithAddress("3.3.3.3", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Details|ds.Port), + gs3 := serverfactory.Build( + serverfactory.WithAddress("3.3.3.3", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Details|ds.Port), ) - gs4 := factories.BuildServer( - factories.WithAddress("4.4.4.4", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.DetailsRetry), + gs4 := serverfactory.Build( + serverfactory.WithAddress("4.4.4.4", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.DetailsRetry), ) - gs5 := factories.BuildServer( - factories.WithAddress("5.5.5.5", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.Info|ds.Details), + gs5 := serverfactory.Build( + serverfactory.WithAddress("5.5.5.5", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.Info|ds.Details), ) - gs6 := factories.BuildServer( - factories.WithAddress("6.6.6.6", 10480), - factories.WithQueryPort(10481), - factories.WithDiscoveryStatus(ds.Master|ds.PortRetry), + gs6 := serverfactory.Build( + serverfactory.WithAddress("6.6.6.6", 10480), + serverfactory.WithQueryPort(10481), + serverfactory.WithDiscoveryStatus(ds.Master|ds.PortRetry), ) app, cancel := makeAppWithReviver( @@ -122,7 +124,7 @@ func TestReviver_OK(t *testing.T) { app.Start(ctx) // nolint: errcheck for _, gs := range []server.Server{gs1, gs2, gs3, gs4, gs5, gs6} { - factories.SaveServer(ctx, serverRepo, gs) + serverfactory.Save(ctx, serverRepo, gs) } // let refresher run a cycle @@ -133,7 +135,9 @@ func TestReviver_OK(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, result.count) assert.Equal(t, 0, result.expired) - assert.Equal(t, []string{"5.5.5.5:10480", "4.4.4.4:10480", "1.1.1.1:10480"}, result.probes) + // because probes are inserted by the use case with a random readiness time, + // we can't predict the order of the probes + assert.ElementsMatch(t, []string{"5.5.5.5:10480", "4.4.4.4:10480", "1.1.1.1:10480"}, result.probes) // make gs3 non-revivable gs3.ClearDiscoveryStatus(ds.Port) @@ -145,7 +149,7 @@ func TestReviver_OK(t *testing.T) { require.NoError(t, err) assert.Equal(t, 4, result.count) assert.Equal(t, 0, result.expired) - assert.Equal(t, []string{"3.3.3.3:10480", "5.5.5.5:10480", "4.4.4.4:10480", "1.1.1.1:10480"}, result.probes) + assert.ElementsMatch(t, []string{"3.3.3.3:10480", "5.5.5.5:10480", "4.4.4.4:10480", "1.1.1.1:10480"}, result.probes) // run a couple of cycles, expect some probes to expire <-time.After(time.Millisecond * 200) @@ -153,7 +157,7 @@ func TestReviver_OK(t *testing.T) { require.NoError(t, err) assert.Equal(t, 8, result.count) assert.Equal(t, 4, result.expired) - assert.Equal(t, []string{"3.3.3.3:10480", "5.5.5.5:10480", "4.4.4.4:10480", "1.1.1.1:10480"}, result.probes) + assert.ElementsMatch(t, []string{"3.3.3.3:10480", "5.5.5.5:10480", "4.4.4.4:10480", "1.1.1.1:10480"}, result.probes) // make the remaining servers non-revivable gs1.UpdateDiscoveryStatus(ds.Port) diff --git a/tests/testapp/providers.go b/tests/testapp/providers.go new file mode 100644 index 0000000..ba0e1af --- /dev/null +++ b/tests/testapp/providers.go @@ -0,0 +1,35 @@ +package testapp + +import ( + "context" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog" + "go.uber.org/fx" +) + +func ProvidePersistence(lc fx.Lifecycle) (*redis.Client, error) { + mr, err := miniredis.Run() + if err != nil { + return nil, err + } + + rdb := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + + lc.Append(fx.Hook{ + OnStop: func(_ context.Context) error { + defer mr.Close() + return rdb.Close() + }, + }) + + return rdb, nil +} + +func NoLogging() *zerolog.Logger { + logger := zerolog.Nop() + return &logger +}