From 9668c23cc824748dc0612fbafb4f785d07b9fb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Thu, 28 Dec 2023 10:56:59 +0000 Subject: [PATCH] feat: tribe repository (#12) Reviewed-on: https://gitea.dwysokinski.me/twhelp/corev3/pulls/12 --- cmd/twhelp/cmd_consumer.go | 2 +- go.mod | 9 +- go.sum | 24 +- internal/adapter/adaptertest/fixture.go | 1 + internal/adapter/adaptertest/postgres.go | 6 +- internal/adapter/adaptertest/sqlite.go | 16 +- internal/adapter/adaptertest/utils.go | 5 + internal/adapter/bun_utils.go | 14 + .../internal/bunmodel/opponents_defeated.go | 48 ++ internal/adapter/internal/bunmodel/tribe.go | 87 ++++ internal/adapter/repository_bun_server.go | 4 +- internal/adapter/repository_bun_tribe.go | 167 +++++++ internal/adapter/repository_bun_tribe_test.go | 29 ++ internal/adapter/repository_server_test.go | 86 ++-- internal/adapter/repository_test.go | 8 + internal/adapter/repository_tribe_test.go | 390 ++++++++++++++++ internal/adapter/repository_version_test.go | 3 +- internal/adapter/testdata/fixture.yml | 424 ++++++++++++++++++ internal/app/service_tribe.go | 11 +- internal/domain/base_server.go | 18 +- internal/domain/base_server_test.go | 6 +- internal/domain/base_tribe.go | 109 ++--- internal/domain/base_tribe_test.go | 2 +- internal/domain/domaintest/base_tribe.go | 24 +- internal/domain/domaintest/tribe.go | 68 ++- internal/domain/opponents_defeated.go | 58 +-- internal/domain/server.go | 55 +-- internal/domain/server_message_payloads.go | 53 ++- internal/domain/server_test.go | 19 +- internal/domain/tribe.go | 345 ++++++++++++++ internal/domain/tribe_test.go | 419 +++++++++++++++++ internal/domain/validation.go | 112 ++++- internal/domain/version.go | 27 +- 33 files changed, 2368 insertions(+), 281 deletions(-) create mode 100644 internal/adapter/bun_utils.go create mode 100644 internal/adapter/internal/bunmodel/opponents_defeated.go create mode 100644 internal/adapter/internal/bunmodel/tribe.go create mode 100644 internal/adapter/repository_bun_tribe.go create mode 100644 internal/adapter/repository_bun_tribe_test.go create mode 100644 internal/adapter/repository_tribe_test.go create mode 100644 internal/domain/tribe_test.go diff --git a/cmd/twhelp/cmd_consumer.go b/cmd/twhelp/cmd_consumer.go index 2044b11..b9dba2e 100644 --- a/cmd/twhelp/cmd_consumer.go +++ b/cmd/twhelp/cmd_consumer.go @@ -92,7 +92,7 @@ var cmdConsumer = &cli.Command{ } consumer := port.NewTribeWatermillConsumer( - app.NewTribeService(twSvc), + app.NewTribeService(adapter.NewTribeBunRepository(db), twSvc), subscriber, logger, marshaler, diff --git a/go.mod b/go.mod index 64ae77b..8672980 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/go-chi/chi/v5 v5.0.10 github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.3.1 - github.com/gosimple/slug v1.13.1 github.com/ory/dockertest/v3 v3.10.0 github.com/stretchr/testify v1.8.4 github.com/uptrace/bun v1.1.16 @@ -20,6 +19,7 @@ require ( github.com/uptrace/bun/dialect/sqlitedialect v1.1.16 github.com/uptrace/bun/driver/pgdriver v1.1.16 github.com/uptrace/bun/driver/sqliteshim v1.1.16 + github.com/uptrace/bun/extra/bundebug v1.1.16 github.com/urfave/cli/v2 v2.26.0 ) @@ -36,15 +36,16 @@ require ( github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.15.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect @@ -68,9 +69,9 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/crypto v0.13.0 // indirect - golang.org/x/mod v0.12.0 // indirect + golang.org/x/mod v0.14.0 // indirect golang.org/x/sys v0.12.0 // indirect - golang.org/x/tools v0.13.0 // indirect + golang.org/x/tools v0.16.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.3.0 // indirect diff --git a/go.sum b/go.sum index a9f9b1b..0e7a541 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/phpserialize v1.3.3 h1:hV4QVmGdCiYgoBbw+ADt6fNgyZ2mYX0OgpnON1adTCM= github.com/elliotchance/phpserialize v1.3.3/go.mod h1:gt7XX9+ETUcLXbtTKEuyrqW3lcLUAeS/AnGZ2e49TZs= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= @@ -65,10 +67,6 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gosimple/slug v1.13.1 h1:bQ+kpX9Qa6tHRaK+fZR0A0M2Kd7Pa5eHPPsb1JpHD+Q= -github.com/gosimple/slug v1.13.1/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= -github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= -github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -92,6 +90,9 @@ github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2 h1:hRGSmZu7j271trc9sneMrpOW github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= @@ -159,6 +160,8 @@ github.com/uptrace/bun/driver/pgdriver v1.1.16 h1:b/NiSXk6Ldw7KLfMLbOqIkm4odHd7Q github.com/uptrace/bun/driver/pgdriver v1.1.16/go.mod h1:Rmfbc+7lx1z/umjMyAxkOHK81LgnGj71XC5YpA6k1vU= github.com/uptrace/bun/driver/sqliteshim v1.1.16 h1:dhXrdXQegGSM+Jk07s9p1Y575DMhCKl7wBkss8kgM+8= github.com/uptrace/bun/driver/sqliteshim v1.1.16/go.mod h1:7fJaPqaiSvnWytQcMwVv1ZcjUJh7tGDonO/zq3O6RRI= +github.com/uptrace/bun/extra/bundebug v1.1.16 h1:SgicRQGtnjhrIhlYOxdkOm1Em4s6HykmT3JblHnoTBM= +github.com/uptrace/bun/extra/bundebug v1.1.16/go.mod h1:SkiOkfUirBiO1Htc4s5bQKEq+JSeU1TkBVpMsPz2ePM= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI= github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= @@ -187,8 +190,8 @@ golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -198,8 +201,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -212,6 +215,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -223,8 +227,8 @@ golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/adapter/adaptertest/fixture.go b/internal/adapter/adaptertest/fixture.go index ec2210d..2ce69d1 100644 --- a/internal/adapter/adaptertest/fixture.go +++ b/internal/adapter/adaptertest/fixture.go @@ -19,6 +19,7 @@ func NewFixture(bunDB *bun.DB) *Fixture { bunDB.RegisterModel( (*bunmodel.Version)(nil), (*bunmodel.Server)(nil), + (*bunmodel.Tribe)(nil), ) return &Fixture{ f: dbfixture.New(bunDB), diff --git a/internal/adapter/adaptertest/postgres.go b/internal/adapter/adaptertest/postgres.go index ce1ebd9..ba63cf8 100644 --- a/internal/adapter/adaptertest/postgres.go +++ b/internal/adapter/adaptertest/postgres.go @@ -141,7 +141,7 @@ func (p *Postgres) NewBunDB(tb TestingTB) *bun.DB { schema := generatePostgresSchema() - sqldb := sql.OpenDB( + sqlDB := sql.OpenDB( pgdriver.NewConnector( pgdriver.WithDSN(p.connectionString.String()), pgdriver.WithConnParams(map[string]any{ @@ -150,11 +150,13 @@ func (p *Postgres) NewBunDB(tb TestingTB) *bun.DB { ), ) - bunDB := bun.NewDB(sqldb, pgdialect.New()) + bunDB := bun.NewDB(sqlDB, pgdialect.New()) tb.Cleanup(func() { _ = bunDB.Close() }) + bunDB.AddQueryHook(newBunDebugHook()) + bo := backoff.NewExponentialBackOff() bo.MaxInterval = postgresPingBackOffMaxInterval bo.MaxElapsedTime = postgresPingBackOffMaxElapsedTime diff --git a/internal/adapter/adaptertest/sqlite.go b/internal/adapter/adaptertest/sqlite.go index 0a1311a..62f680b 100644 --- a/internal/adapter/adaptertest/sqlite.go +++ b/internal/adapter/adaptertest/sqlite.go @@ -12,15 +12,17 @@ import ( // NewBunDBSQLite initializes a new instance of *bun.DB, which is ready for use (all required migrations are applied). // Data is stored in memory (https://www.sqlite.org/inmemorydb.html). func NewBunDBSQLite(tb TestingTB) *bun.DB { - sqldb, err := sql.Open(sqliteshim.ShimName, "file::memory:") + sqlDB, err := sql.Open(sqliteshim.ShimName, "file::memory:") require.NoError(tb, err) - sqldb.SetMaxOpenConns(1) - sqldb.SetMaxIdleConns(1) - sqldb.SetConnMaxLifetime(0) + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + sqlDB.SetConnMaxLifetime(0) - db := bun.NewDB(sqldb, sqlitedialect.New()) + bunDB := bun.NewDB(sqlDB, sqlitedialect.New()) - runMigrations(tb, db) + bunDB.AddQueryHook(newBunDebugHook()) - return db + runMigrations(tb, bunDB) + + return bunDB } diff --git a/internal/adapter/adaptertest/utils.go b/internal/adapter/adaptertest/utils.go index e6482d1..08c3af1 100644 --- a/internal/adapter/adaptertest/utils.go +++ b/internal/adapter/adaptertest/utils.go @@ -13,6 +13,7 @@ import ( "github.com/ory/dockertest/v3" "github.com/stretchr/testify/require" "github.com/uptrace/bun" + "github.com/uptrace/bun/extra/bundebug" ) const migrationTimeout = 10 * time.Second @@ -55,3 +56,7 @@ func retry(bo backoff.BackOff, op backoff.Operation) error { return nil } + +func newBunDebugHook() *bundebug.QueryHook { + return bundebug.NewQueryHook(bundebug.FromEnv("BUNDEBUG")) +} diff --git a/internal/adapter/bun_utils.go b/internal/adapter/bun_utils.go new file mode 100644 index 0000000..fd9bc37 --- /dev/null +++ b/internal/adapter/bun_utils.go @@ -0,0 +1,14 @@ +package adapter + +import "github.com/uptrace/bun" + +func appendODSetClauses(q *bun.InsertQuery) *bun.InsertQuery { + return q.Set("rank_att = EXCLUDED.rank_att"). + Set("score_att = EXCLUDED.score_att"). + Set("rank_def = EXCLUDED.rank_def"). + Set("score_def = EXCLUDED.score_def"). + Set("rank_sup = EXCLUDED.rank_sup"). + Set("score_sup = EXCLUDED.score_sup"). + Set("rank_total = EXCLUDED.rank_total"). + Set("score_total = EXCLUDED.score_total") +} diff --git a/internal/adapter/internal/bunmodel/opponents_defeated.go b/internal/adapter/internal/bunmodel/opponents_defeated.go new file mode 100644 index 0000000..1e4edd3 --- /dev/null +++ b/internal/adapter/internal/bunmodel/opponents_defeated.go @@ -0,0 +1,48 @@ +package bunmodel + +import ( + "fmt" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" +) + +type OpponentsDefeated struct { + RankAtt int `bun:"rank_att"` + ScoreAtt int `bun:"score_att"` + RankDef int `bun:"rank_def"` + ScoreDef int `bun:"score_def"` + RankSup int `bun:"rank_sup"` + ScoreSup int `bun:"score_sup"` + RankTotal int `bun:"rank_total"` + ScoreTotal int `bun:"score_total"` +} + +func NewOpponentsDefeated(od domain.OpponentsDefeated) OpponentsDefeated { + return OpponentsDefeated{ + RankAtt: od.RankAtt(), + ScoreAtt: od.ScoreAtt(), + RankDef: od.RankDef(), + ScoreDef: od.ScoreDef(), + RankSup: od.RankSup(), + ScoreSup: od.ScoreSup(), + RankTotal: od.RankTotal(), + ScoreTotal: od.ScoreTotal(), + } +} + +func (od OpponentsDefeated) ToDomain() (domain.OpponentsDefeated, error) { + converted, err := domain.NewOpponentsDefeated( + od.RankAtt, + od.ScoreAtt, + od.RankDef, + od.ScoreDef, + od.RankSup, + od.ScoreSup, + od.RankTotal, + od.ScoreTotal, + ) + if err != nil { + return domain.OpponentsDefeated{}, fmt.Errorf("couldn't construct domain.OpponentsDefeated: %w", err) + } + return converted, nil +} diff --git a/internal/adapter/internal/bunmodel/tribe.go b/internal/adapter/internal/bunmodel/tribe.go new file mode 100644 index 0000000..a09bf0b --- /dev/null +++ b/internal/adapter/internal/bunmodel/tribe.go @@ -0,0 +1,87 @@ +package bunmodel + +import ( + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/uptrace/bun" +) + +type Tribe struct { + bun.BaseModel `bun:"table:tribes,alias:tribe"` + + ID int `bun:"id,nullzero,pk"` + ServerKey string `bun:"server_key,nullzero,pk"` + Name string `bun:"name,nullzero"` + Tag string `bun:"tag,nullzero"` + NumMembers int `bun:"num_members"` + NumVillages int `bun:"num_villages"` + Points int `bun:"points"` + AllPoints int `bun:"all_points"` + Rank int `bun:"rank"` + Dominance float64 `bun:"dominance"` + ProfileURL string `bun:"profile_url,nullzero"` + BestRank int `bun:"best_rank,nullzero,default:999999"` + BestRankAt time.Time `bun:"best_rank_at,nullzero,default:CURRENT_TIMESTAMP"` + MostPoints int `bun:"most_points"` + MostPointsAt time.Time `bun:"most_points_at,nullzero,default:CURRENT_TIMESTAMP"` + MostVillages int `bun:"most_villages"` + MostVillagesAt time.Time `bun:"most_villages_at,nullzero,default:CURRENT_TIMESTAMP"` + CreatedAt time.Time `bun:"created_at,nullzero,default:CURRENT_TIMESTAMP"` + DeletedAt time.Time `bun:"deleted_at,nullzero"` + + OpponentsDefeated +} + +func (t Tribe) ToDomain() (domain.Tribe, error) { + od, err := t.OpponentsDefeated.ToDomain() + if err != nil { + return domain.Tribe{}, fmt.Errorf("couldn't construct domain.Tribe (id=%d,serverKey=%s): %w", t.ID, t.ServerKey, err) + } + + converted, err := domain.UnmarshalTribeFromDatabase( + t.ID, + t.ServerKey, + t.Name, + t.Tag, + t.NumMembers, + t.NumVillages, + t.Points, + t.AllPoints, + t.Rank, + od, + t.ProfileURL, + t.Dominance, + t.BestRank, + t.BestRankAt, + t.MostPoints, + t.MostPointsAt, + t.MostVillages, + t.MostVillagesAt, + t.CreatedAt, + t.DeletedAt, + ) + if err != nil { + return domain.Tribe{}, fmt.Errorf("couldn't construct domain.Tribe (id=%d,serverKey=%s): %w", t.ID, t.ServerKey, err) + } + + return converted, nil +} + +type Tribes []Tribe + +func (ts Tribes) ToDomain() (domain.Tribes, error) { + res := make(domain.Tribes, 0, len(ts)) + + for _, t := range ts { + converted, err := t.ToDomain() + if err != nil { + return nil, err + } + + res = append(res, converted) + } + + return res, nil +} diff --git a/internal/adapter/repository_bun_server.go b/internal/adapter/repository_bun_server.go index 87fd990..e9486ed 100644 --- a/internal/adapter/repository_bun_server.go +++ b/internal/adapter/repository_bun_server.go @@ -146,8 +146,6 @@ func (a listServersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { q = q.Where("server.special = ?", special.Value) } - q = q.Limit(a.params.Limit()).Offset(a.params.Offset()) - for _, s := range a.params.Sort() { switch s { case domain.ServerSortKeyASC: @@ -163,5 +161,5 @@ func (a listServersParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { } } - return q + return q.Limit(a.params.Limit()).Offset(a.params.Offset()) } diff --git a/internal/adapter/repository_bun_tribe.go b/internal/adapter/repository_bun_tribe.go new file mode 100644 index 0000000..9e0426c --- /dev/null +++ b/internal/adapter/repository_bun_tribe.go @@ -0,0 +1,167 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/adapter/internal/bunmodel" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" +) + +type TribeBunRepository struct { + db bun.IDB +} + +func NewTribeBunRepository(db bun.IDB) *TribeBunRepository { + return &TribeBunRepository{db: db} +} + +func (repo *TribeBunRepository) CreateOrUpdate(ctx context.Context, params ...domain.CreateTribeParams) error { + if len(params) == 0 { + return nil + } + + tribes := make(bunmodel.Tribes, 0, len(params)) + + for _, p := range params { + base := p.Base() + tribes = append(tribes, bunmodel.Tribe{ + ID: base.ID(), + ServerKey: p.ServerKey(), + Name: base.Name(), + Tag: base.Tag(), + NumMembers: base.NumMembers(), + NumVillages: base.NumVillages(), + Points: base.Points(), + AllPoints: base.AllPoints(), + Rank: base.Rank(), + ProfileURL: base.ProfileURL().String(), + BestRank: p.BestRank(), + BestRankAt: p.BestRankAt(), + MostPoints: p.MostPoints(), + MostPointsAt: p.MostPointsAt(), + MostVillages: p.MostVillages(), + MostVillagesAt: p.MostVillagesAt(), + CreatedAt: time.Now(), + OpponentsDefeated: bunmodel.NewOpponentsDefeated(base.OD()), + }) + } + + q := repo.db.NewInsert(). + Model(&tribes) + + //nolint:exhaustive + switch q.Dialect().Name() { + case dialect.PG: + q = q.On("CONFLICT ON CONSTRAINT tribes_pkey DO UPDATE") + case dialect.SQLite: + q = q.On("CONFLICT(id, server_key) DO UPDATE") + default: + q = q.Err(errors.New("unsupported dialect")) + } + + if _, err := q. + Set("name = EXCLUDED.name"). + Set("tag = EXCLUDED.tag"). + Set("num_members = EXCLUDED.num_members"). + Set("num_villages = EXCLUDED.num_villages"). + Set("points = EXCLUDED.points"). + Set("all_points = EXCLUDED.all_points"). + Set("rank = EXCLUDED.rank"). + Set("profile_url = EXCLUDED.profile_url"). + Set("best_rank = EXCLUDED.best_rank"). + Set("best_rank_at = EXCLUDED.best_rank_at"). + Set("most_villages = EXCLUDED.most_villages"). + Set("most_villages_at = EXCLUDED.most_villages_at"). + Set("most_points = EXCLUDED.most_points"). + Set("most_points_at = EXCLUDED.most_points_at"). + Set("deleted_at = EXCLUDED.deleted_at"). + Apply(appendODSetClauses). + Returning(""). + Exec(ctx); err != nil { + return fmt.Errorf("something went wrong while inserting tribes into the db: %w", err) + } + + return nil +} + +func (repo *TribeBunRepository) List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error) { + var servers bunmodel.Tribes + + if err := repo.db.NewSelect(). + Model(&servers). + Apply(listTribesParamsApplier{params: params}.apply). + Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("couldn't select tribes from the db: %w", err) + } + + return servers.ToDomain() +} + +func (repo *TribeBunRepository) Delete(ctx context.Context, serverKey string, ids ...int) error { + if len(ids) == 0 { + return nil + } + + if _, err := repo.db.NewUpdate(). + Model((*bunmodel.Tribe)(nil)). + Where("deleted_at IS NULL"). + Where("id IN (?)", bun.In(ids)). + Where("server_key = ?", serverKey). + Set("deleted_at = ?", time.Now()). + Returning(""). + Exec(ctx); err != nil { + return fmt.Errorf("couldn't delete tribes: %w", err) + } + + return nil +} + +type listTribesParamsApplier struct { + params domain.ListTribesParams +} + +//nolint:gocyclo +func (a listTribesParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery { + if ids := a.params.IDs(); len(ids) > 0 { + q = q.Where("tribe.id IN (?)", bun.In(ids)) + } + + if idGT := a.params.IDGT(); idGT.Valid { + q = q.Where("tribe.id > ?", idGT.Value) + } + + if serverKeys := a.params.ServerKeys(); len(serverKeys) > 0 { + q = q.Where("tribe.server_key IN (?)", bun.In(serverKeys)) + } + + if deleted := a.params.Deleted(); deleted.Valid { + if deleted.Value { + q = q.Where("tribe.deleted_at IS NOT NULL") + } else { + q = q.Where("tribe.deleted_at IS NULL") + } + } + + for _, s := range a.params.Sort() { + switch s { + case domain.TribeSortIDASC: + q = q.Order("tribe.id ASC") + case domain.TribeSortIDDESC: + q = q.Order("tribe.id DESC") + case domain.TribeSortServerKeyASC: + q = q.Order("tribe.server_key ASC") + case domain.TribeSortServerKeyDESC: + q = q.Order("tribe.server_key DESC") + default: + return q.Err(errors.New("unsupported sort value")) + } + } + + return q.Limit(a.params.Limit()).Offset(a.params.Offset()) +} diff --git a/internal/adapter/repository_bun_tribe_test.go b/internal/adapter/repository_bun_tribe_test.go new file mode 100644 index 0000000..de1a1df --- /dev/null +++ b/internal/adapter/repository_bun_tribe_test.go @@ -0,0 +1,29 @@ +package adapter_test + +import ( + "testing" + + "gitea.dwysokinski.me/twhelp/corev3/internal/adapter/adaptertest" +) + +func TestTribeBunRepository_Postgres(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping long-running test") + } + + testTribeRepository(t, func(t *testing.T) repositories { + t.Helper() + return newBunDBRepositories(t, postgres.NewBunDB(t)) + }) +} + +func TestTribeBunRepository_SQLite(t *testing.T) { + t.Parallel() + + testTribeRepository(t, func(t *testing.T) repositories { + t.Helper() + return newBunDBRepositories(t, adaptertest.NewBunDBSQLite(t)) + }) +} diff --git a/internal/adapter/repository_server_test.go b/internal/adapter/repository_server_test.go index 2fd3c90..12d3d16 100644 --- a/internal/adapter/repository_server_test.go +++ b/internal/adapter/repository_server_test.go @@ -4,13 +4,11 @@ import ( "cmp" "context" "fmt" - "net/url" "slices" "testing" "gitea.dwysokinski.me/twhelp/corev3/internal/domain" "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" - "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,11 +21,39 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories t.Run("CreateOrUpdate", func(t *testing.T) { t.Parallel() + repos := newRepos(t) + + assertCreatedUpdated := func(t *testing.T, params []domain.CreateServerParams) { + t.Helper() + + require.NotEmpty(t, params) + + keys := make([]string, 0, len(params)) + for _, p := range params { + keys = append(keys, p.Base().Key()) + } + + listParams := domain.NewListServersParams() + require.NoError(t, listParams.SetKeys(keys)) + + servers, err := repos.server.List(ctx, listParams) + require.NoError(t, err) + require.Len(t, servers, len(params)) + for i, p := range params { + idx := slices.IndexFunc(servers, func(server domain.Server) bool { + return server.Key() == p.Base().Key() + }) + require.GreaterOrEqualf(t, idx, 0, "params[%d]", i) + server := servers[idx] + + assert.Equalf(t, p.Base(), server.Base(), "params[%d]", i) + assert.Equalf(t, p.VersionCode(), server.VersionCode(), "params[%d]", i) + } + } + t.Run("OK", func(t *testing.T) { t.Parallel() - repos := newRepos(t) - versions, err := repos.version.List(ctx, domain.NewListVersionsParams()) require.NoError(t, err) require.NotEmpty(t, versions) @@ -42,31 +68,11 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories require.NoError(t, err) require.NoError(t, repos.server.CreateOrUpdate(ctx, createParams...)) - - keys := make([]string, 0, len(serversToCreate)) - for _, s := range serversToCreate { - keys = append(keys, s.Key()) - } - - listCreatedServersParams := domain.NewListServersParams() - require.NoError(t, listCreatedServersParams.SetKeys(keys)) - - createdServers, err := repos.server.List(ctx, listCreatedServersParams) - require.NoError(t, err) - require.Len(t, createdServers, len(serversToCreate)) - for _, base := range serversToCreate { - assert.True(t, slices.ContainsFunc(createdServers, func(server domain.Server) bool { - return server.Key() == base.Key() && - server.Open() == base.Open() && - server.URL().String() == base.URL().String() && - server.VersionCode() == version.Code() - })) - } + assertCreatedUpdated(t, createParams) serversToUpdate := domain.BaseServers{ domaintest.NewBaseServer(t, func(cfg *domaintest.BaseServerConfig) { cfg.Key = serversToCreate[0].Key() - cfg.URL = randURL(t) cfg.Open = !serversToCreate[0].Open() }), } @@ -75,26 +81,13 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories require.NoError(t, err) require.NoError(t, repos.server.CreateOrUpdate(ctx, updateParams...)) + assertCreatedUpdated(t, updateParams) + }) - keys = make([]string, 0, len(serversToUpdate)) - for _, s := range serversToUpdate { - keys = append(keys, s.Key()) - } + t.Run("OK: len(params) == 0", func(t *testing.T) { + t.Parallel() - listUpdatedServersParams := domain.NewListServersParams() - require.NoError(t, listUpdatedServersParams.SetKeys(keys)) - - updatedServers, err := repos.server.List(ctx, listUpdatedServersParams) - require.NoError(t, err) - require.Len(t, updatedServers, len(serversToUpdate)) - for _, base := range serversToUpdate { - assert.True(t, slices.ContainsFunc(updatedServers, func(server domain.Server) bool { - return server.Key() == base.Key() && - server.Open() == base.Open() && - server.URL().String() == base.URL().String() && - server.VersionCode() == version.Code() - })) - } + require.NoError(t, repos.server.CreateOrUpdate(ctx)) }) }) @@ -447,10 +440,3 @@ func testServerRepository(t *testing.T, newRepos func(t *testing.T) repositories }) }) } - -func randURL(tb testing.TB) *url.URL { - tb.Helper() - u, err := url.Parse("https://" + gofakeit.DomainName()) - require.NoError(tb, err) - return u -} diff --git a/internal/adapter/repository_test.go b/internal/adapter/repository_test.go index 64d0771..643d37b 100644 --- a/internal/adapter/repository_test.go +++ b/internal/adapter/repository_test.go @@ -25,9 +25,16 @@ type serverRepository interface { Update(ctx context.Context, key string, params domain.UpdateServerParams) error } +type tribeRepository interface { + CreateOrUpdate(ctx context.Context, params ...domain.CreateTribeParams) error + List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error) + Delete(ctx context.Context, serverKey string, ids ...int) error +} + type repositories struct { version versionRepository server serverRepository + tribe tribeRepository } func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories { @@ -38,5 +45,6 @@ func newBunDBRepositories(tb testing.TB, bunDB *bun.DB) repositories { return repositories{ version: adapter.NewVersionBunRepository(bunDB), server: adapter.NewServerBunRepository(bunDB), + tribe: adapter.NewTribeBunRepository(bunDB), } } diff --git a/internal/adapter/repository_tribe_test.go b/internal/adapter/repository_tribe_test.go new file mode 100644 index 0000000..37a792c --- /dev/null +++ b/internal/adapter/repository_tribe_test.go @@ -0,0 +1,390 @@ +package adapter_test + +import ( + "cmp" + "context" + "fmt" + "math" + "slices" + "testing" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testTribeRepository(t *testing.T, newRepos func(t *testing.T) repositories) { + t.Helper() + + ctx := context.Background() + + t.Run("CreateOrUpdate", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + assertCreatedUpdated := func(t *testing.T, params []domain.CreateTribeParams) { + t.Helper() + + require.NotEmpty(t, params) + + ids := make([]int, 0, len(params)) + for _, p := range params { + ids = append(ids, p.Base().ID()) + } + + listParams := domain.NewListTribesParams() + require.NoError(t, listParams.SetIDs(ids)) + require.NoError(t, listParams.SetServerKeys([]string{params[0].ServerKey()})) + + tribes, err := repos.tribe.List(ctx, listParams) + require.NoError(t, err) + assert.Len(t, tribes, len(params)) + for i, p := range params { + idx := slices.IndexFunc(tribes, func(tribe domain.Tribe) bool { + return tribe.ID() == p.Base().ID() && tribe.ServerKey() == p.ServerKey() + }) + require.GreaterOrEqualf(t, idx, 0, "params[%d]", i) + tribe := tribes[idx] + + assert.Equalf(t, p.Base(), tribe.Base(), "params[%d]", i) + assert.Equalf(t, p.ServerKey(), tribe.ServerKey(), "params[%d]", i) + assert.Equalf(t, p.BestRank(), tribe.BestRank(), "params[%d]", i) + assert.WithinDurationf(t, p.BestRankAt(), tribe.BestRankAt(), time.Minute, "params[%d]", i) + assert.Equalf(t, p.MostVillages(), tribe.MostVillages(), "params[%d]", i) + assert.WithinDurationf(t, p.MostVillagesAt(), tribe.MostVillagesAt(), time.Minute, "params[%d]", i) + assert.Equalf(t, p.MostPoints(), tribe.MostPoints(), "params[%d]", i) + assert.WithinDurationf(t, p.MostPointsAt(), tribe.MostPointsAt(), time.Minute, "params[%d]", i) + } + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + servers, err := repos.server.List(ctx, domain.NewListServersParams()) + require.NoError(t, err) + require.NotEmpty(t, servers) + server := servers[0] + + tribesToCreate := domain.BaseTribes{ + domaintest.NewBaseTribe(t), + domaintest.NewBaseTribe(t), + } + + createParams, err := domain.NewCreateTribeParams(server.Key(), tribesToCreate, nil) + require.NoError(t, err) + + require.NoError(t, repos.tribe.CreateOrUpdate(ctx, createParams...)) + assertCreatedUpdated(t, createParams) + + tribesToUpdate := domain.BaseTribes{ + domaintest.NewBaseTribe(t, func(cfg *domaintest.BaseTribeConfig) { + cfg.ID = tribesToCreate[0].ID() + }), + } + + updateParams, err := domain.NewCreateTribeParams(server.Key(), tribesToUpdate, nil) + require.NoError(t, err) + + require.NoError(t, repos.tribe.CreateOrUpdate(ctx, updateParams...)) + assertCreatedUpdated(t, updateParams) + }) + + t.Run("OK: len(params) == 0", func(t *testing.T) { + t.Parallel() + + require.NoError(t, repos.tribe.CreateOrUpdate(ctx)) + }) + }) + + t.Run("List & ListCount", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + tribes, listTribesErr := repos.tribe.List(ctx, domain.NewListTribesParams()) + require.NoError(t, listTribesErr) + require.NotEmpty(t, tribes) + randTribe := tribes[0] + + tests := []struct { + name string + params func(t *testing.T) domain.ListTribesParams + assertTribes func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) + assertError func(t *testing.T, err error) + assertTotal func(t *testing.T, params domain.ListTribesParams, total int) + }{ + { + name: "OK: default params", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + return domain.NewListTribesParams() + }, + assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + t.Helper() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) int { + if x := cmp.Compare(a.ServerKey(), b.ServerKey()); x != 0 { + return x + } + return cmp.Compare(a.ID(), b.ID()) + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListTribesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: sort=[serverKey DESC, id DESC]", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetSort([]domain.TribeSort{domain.TribeSortServerKeyDESC, domain.TribeSortIDDESC})) + return params + }, + assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + t.Helper() + assert.NotEmpty(t, len(tribes)) + assert.True(t, slices.IsSortedFunc(tribes, func(a, b domain.Tribe) int { + if x := cmp.Compare(a.ServerKey(), b.ServerKey()) * -1; x != 0 { + return x + } + return cmp.Compare(a.ID(), b.ID()) * -1 + })) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListTribesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: fmt.Sprintf("OK: ids=[%d] serverKeys=[%s]", randTribe.ID(), randTribe.ServerKey()), + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetIDs([]int{randTribe.ID()})) + require.NoError(t, params.SetServerKeys([]string{randTribe.ServerKey()})) + return params + }, + assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + t.Helper() + + ids := params.IDs() + serverKeys := params.ServerKeys() + + for _, tr := range tribes { + assert.True(t, slices.Contains(ids, tr.ID())) + assert.True(t, slices.Contains(serverKeys, tr.ServerKey())) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListTribesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: fmt.Sprintf("OK: idGT=%d", randTribe.ID()), + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetIDGT(domain.NullInt{ + Value: randTribe.ID(), + Valid: true, + })) + return params + }, + assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + t.Helper() + assert.NotEmpty(t, tribes) + for _, tr := range tribes { + assert.Greater(t, tr.ID(), params.IDGT().Value, tr.ID()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListTribesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: deleted=true", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetDeleted(domain.NullBool{ + Value: true, + Valid: true, + })) + return params + }, + assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + t.Helper() + assert.NotEmpty(t, tribes) + for _, s := range tribes { + assert.True(t, s.IsDeleted()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListTribesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: deleted=false", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetDeleted(domain.NullBool{ + Value: false, + Valid: true, + })) + return params + }, + assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + t.Helper() + assert.NotEmpty(t, tribes) + for _, s := range tribes { + assert.False(t, s.IsDeleted()) + } + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListTribesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + { + name: "OK: offset=1 limit=2", + params: func(t *testing.T) domain.ListTribesParams { + t.Helper() + params := domain.NewListTribesParams() + require.NoError(t, params.SetOffset(1)) + require.NoError(t, params.SetLimit(2)) + return params + }, + assertTribes: func(t *testing.T, params domain.ListTribesParams, tribes domain.Tribes) { + t.Helper() + assert.Len(t, tribes, params.Limit()) + }, + assertError: func(t *testing.T, err error) { + t.Helper() + require.NoError(t, err) + }, + assertTotal: func(t *testing.T, params domain.ListTribesParams, total int) { + t.Helper() + assert.NotEmpty(t, total) + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := tt.params(t) + + res, err := repos.tribe.List(ctx, params) + tt.assertError(t, err) + tt.assertTribes(t, params, res) + }) + } + }) + + t.Run("Delete", func(t *testing.T) { + t.Parallel() + + repos := newRepos(t) + + listServersParams := domain.NewListServersParams() + require.NoError(t, listServersParams.SetSpecial(domain.NullBool{Value: false, Valid: true})) + servers, listServersErr := repos.server.List(ctx, listServersParams) + require.NoError(t, listServersErr) + require.NotEmpty(t, servers) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + serverKeys := make([]string, 0, len(servers)) + for _, s := range servers { + serverKeys = append(serverKeys, s.Key()) + } + + listTribesParams := domain.NewListTribesParams() + require.NoError(t, listTribesParams.SetDeleted(domain.NullBool{Value: false, Valid: true})) + require.NoError(t, listTribesParams.SetServerKeys(serverKeys)) + + tribes, err := repos.tribe.List(ctx, listTribesParams) + require.NoError(t, err) + + var serverKey string + var ids []int + + for _, tr := range tribes { + if serverKey == "" { + serverKey = tr.ServerKey() + } + + if tr.ServerKey() == serverKey { + ids = append(ids, tr.ID()) + } + } + + idsToDelete := ids[:int(math.Ceil(float64(len(ids))/2))] + + require.NoError(t, repos.tribe.Delete(ctx, serverKey, idsToDelete...)) + + listTribesParams = domain.NewListTribesParams() + require.NoError(t, listTribesParams.SetDeleted(domain.NullBool{Valid: false})) + require.NoError(t, listTribesParams.SetServerKeys(serverKeys)) + + tribes, err = repos.tribe.List(ctx, listTribesParams) + require.NoError(t, err) + for _, tr := range tribes { + if tr.ServerKey() == serverKey && slices.Contains(ids, tr.ID()) { + if slices.Contains(idsToDelete, tr.ID()) { + assert.WithinDuration(t, time.Now(), tr.DeletedAt(), time.Minute) + } else { + assert.Zero(t, tr.DeletedAt()) + } + continue + } + + // ensure that no other tribe is removed + assert.WithinRange(t, tr.DeletedAt(), time.Time{}, time.Now().Add(-time.Minute)) + } + }) + + t.Run("OK: len(ids) == 0", func(t *testing.T) { + t.Parallel() + + require.NoError(t, repos.tribe.Delete(ctx, servers[0].Key())) + }) + }) +} diff --git a/internal/adapter/repository_version_test.go b/internal/adapter/repository_version_test.go index a3d1362..1d69418 100644 --- a/internal/adapter/repository_version_test.go +++ b/internal/adapter/repository_version_test.go @@ -14,6 +14,8 @@ import ( func testVersionRepository(t *testing.T, newRepos func(t *testing.T) repositories) { t.Helper() + ctx := context.Background() + t.Run("List & ListCount", func(t *testing.T) { t.Parallel() @@ -80,7 +82,6 @@ func testVersionRepository(t *testing.T, newRepos func(t *testing.T) repositorie t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx := context.Background() params := tt.params(t) res, err := repos.version.List(ctx, params) diff --git a/internal/adapter/testdata/fixture.yml b/internal/adapter/testdata/fixture.yml index 7a2523f..3b3e628 100644 --- a/internal/adapter/testdata/fixture.yml +++ b/internal/adapter/testdata/fixture.yml @@ -76,3 +76,427 @@ village_data_updated_at: 2022-03-19T12:01:39.000Z ennoblement_data_updated_at: 2022-03-19T12:01:39.000Z version_code: pl +- model: Tribe + rows: + - rank_att: 1 + score_att: 730488339 + rank_def: 1 + score_def: 303300128 + rank_sup: 0 + score_sup: 0 + rank_total: 1 + score_total: 1033788467 + _id: de188-obott + id: 772 + server_key: de188 + name: Obdachlose Otter + tag: ObOtt + num_members: 20 + num_villages: 8419 + points: 86151161 + all_points: 86151161 + rank: 1 + dominance: 58.367997781475324 + created_at: 2021-03-07T11:00:51.000Z + profile_url: https://de188.die-staemme.de/game.php?screen=info_ally&id=772 + - rank_att: 4 + score_att: 84537872 + rank_def: 2 + score_def: 233565453 + rank_sup: 0 + score_sup: 0 + rank_total: 2 + score_total: 318103325 + _id: de188-clap + id: 950 + server_key: de188 + name: Klatschn muss et + tag: Clap! + num_members: 4 + num_villages: 333 + points: 3274015 + all_points: 3274015 + rank: 5 + dominance: 2.3086522462562398 + created_at: 2021-03-16T01:00:54.000Z + profile_url: https://de188.die-staemme.de/game.php?screen=info_ally&id=950 + - rank_att: 2 + score_att: 91976316 + rank_def: 21 + score_def: 15324882 + rank_sup: 0 + score_sup: 0 + rank_total: 6 + score_total: 107301198 + _id: de188-rb + id: 1307 + server_key: de188 + name: Rasselbande + tag: -RB- + num_members: 6 + num_villages: 1509 + points: 14729501 + all_points: 14729501 + rank: 2 + dominance: 10.461730449251249 + created_at: 2021-04-17T09:01:01.000Z + profile_url: https://de188.die-staemme.de/game.php?screen=info_ally&id=1307 + - rank_att: 3 + score_att: 87034216 + rank_def: 3 + score_def: 228160403 + rank_sup: 0 + score_sup: 0 + rank_total: 3 + score_total: 315194619 + _id: de188-lost + id: 97 + server_key: de188 + name: Fühl ich nicht. + tag: LOST + num_members: 2 + num_villages: 274 + points: 2541853 + all_points: 2541853 + rank: 7 + dominance: 1.8996117581808099 + created_at: 2021-02-26T15:00:43.000Z + profile_url: https://de188.die-staemme.de/game.php?screen=info_ally&id=97 + - rank_att: 5 + score_att: 81487046 + rank_def: 5 + score_def: 67353196 + rank_sup: 0 + score_sup: 0 + rank_total: 4 + score_total: 148840242 + _id: de188-shnauz + id: 64 + server_key: de188 + name: aufshnauz + tag: shnauz + num_members: 2 + num_villages: 351 + points: 3431912 + all_points: 3431912 + rank: 4 + dominance: 2.4334442595673877 + created_at: 2021-02-24T22:00:55.000Z + profile_url: https://de188.die-staemme.de/game.php?screen=info_ally&id=64 + - rank_att: 1 + score_att: 1449031872 + rank_def: 1 + score_def: 993299748 + rank_sup: 0 + score_sup: 0 + rank_total: 1 + score_total: 2442331620 + _id: en113-kekw + id: 122 + server_key: en113 + name: Identity Crisis + tag: KEKW + num_members: 20 + num_villages: 22568 + points: 266237628 + all_points: 266237628 + rank: 1 + dominance: 54.27478896611433 + created_at: 2020-06-22T13:45:46.000Z + profile_url: https://en113.tribalwars.net/game.php?screen=info_ally&id=122 + - rank_att: 2 + score_att: 508057735 + rank_def: 2 + score_def: 742858106 + rank_sup: 0 + score_sup: 0 + rank_total: 2 + score_total: 1250915841 + _id: en113-pear + id: 112 + server_key: en113 + name: The Pear Bears + tag: Pear + num_members: 6 + num_villages: 804 + points: 7407640 + all_points: 7407640 + rank: 7 + dominance: 1.9335754310863134 + created_at: 2020-06-22T13:45:46.000Z + profile_url: https://en113.tribalwars.net/game.php?screen=info_ally&id=112 + - rank_att: 3 + score_att: 321486919 + rank_def: 2 + score_def: 531735322 + rank_sup: 0 + score_sup: 0 + rank_total: 3 + score_total: 853222241 + _id: en113-virus + id: 1337 + server_key: en113 + name: RIM VIRUS + tag: VIRUS + num_members: 4 + num_villages: 34 + points: 268858 + all_points: 268858 + rank: 30 + dominance: 0 + created_at: 2020-06-22T13:45:46.000Z + deleted_at: 2020-12-03T18:01:07Z + profile_url: https://en113.tribalwars.net/game.php?screen=info_ally&id=1337 + - rank_att: 3 + score_att: 231146512 + rank_def: 5 + score_def: 107383183 + rank_sup: 0 + score_sup: 0 + rank_total: 3 + score_total: 338529695 + _id: en113-rule + id: 2544 + server_key: en113 + name: One Rule to Rule Them All + tag: RULE + num_members: 11 + num_villages: 4529 + points: 47701113 + all_points: 47701113 + rank: 2 + dominance: 10.891993939539693 + created_at: 2020-06-22T13:45:46.000Z + profile_url: https://en113.tribalwars.net/game.php?screen=info_ally&id=2544 + - rank_att: 5 + score_att: 63878040 + rank_def: 3 + score_def: 132316341 + rank_sup: 0 + score_sup: 0 + rank_total: 5 + score_total: 196194381 + _id: en113-pq + id: 1115 + server_key: en113 + name: Promising Quest in your area + tag: ~PQ~ + num_members: 1 + num_villages: 23 + points: 72446 + all_points: 72446 + rank: 40 + dominance: 0 + created_at: 2020-06-22T13:45:46.000Z + deleted_at: 2020-12-07T04:00:56Z + profile_url: https://en113.tribalwars.net/game.php?screen=info_ally&id=1115 + - rank_att: 1 + score_att: 132897 + rank_def: 3 + score_def: 60776 + rank_sup: 0 + score_sup: 0 + rank_total: 1 + score_total: 193673 + _id: it70-tww + id: 5 + server_key: it70 + name: The White Wolves + tag: TWW + num_members: 25 + num_villages: 65 + points: 182130 + all_points: 182130 + rank: 1 + dominance: 3.026070763500931 + created_at: 2022-02-21T20:00:07.000Z + profile_url: https://it70.tribals.it/game.php?screen=info_ally&id=5 + - rank_att: 2 + score_att: 73109 + rank_def: 6 + score_def: 29374 + rank_sup: 0 + score_sup: 0 + rank_total: 4 + score_total: 102483 + _id: it70-tbw + id: 30 + server_key: it70 + name: The Black Wolves + tag: TBW + num_members: 25 + num_villages: 57 + points: 155593 + all_points: 155593 + rank: 2 + dominance: 2.653631284916201 + created_at: 2022-02-24T12:00:05.000Z + profile_url: https://it70.tribals.it/game.php?screen=info_ally&id=30 + - rank_att: 4 + score_att: 48518 + rank_def: 16 + score_def: 7674 + rank_sup: 0 + score_sup: 0 + rank_total: 7 + score_total: 56192 + _id: it70-tprm + id: 1 + server_key: it70 + name: The Pokémon Red Machine + tag: TPRM + num_members: 23 + num_villages: 72 + points: 147717 + all_points: 147717 + rank: 3 + dominance: 3.35195530726257 + created_at: 2022-02-21T19:00:08.000Z + profile_url: https://it70.tribals.it/game.php?screen=info_ally&id=1 + - rank_att: 5 + score_att: 47159 + rank_def: 1 + score_def: 88401 + rank_sup: 0 + score_sup: 0 + rank_total: 2 + score_total: 135560 + _id: it70-tbrm + id: 3 + server_key: it70 + name: The Big Red Machine + tag: TBRM + num_members: 23 + num_villages: 56 + points: 142377 + all_points: 142377 + rank: 4 + dominance: 2.60707635009311 + created_at: 2022-02-21T20:00:07.000Z + profile_url: https://it70.tribals.it/game.php?screen=info_ally&id=3 + - rank_att: 6 + score_att: 46008 + rank_def: 2 + score_def: 74025 + rank_sup: 0 + score_sup: 0 + rank_total: 3 + score_total: 120033 + _id: it70-tsrm + id: 31 + server_key: it70 + name: The Small Red Machine + tag: TSRM + num_members: 25 + num_villages: 46 + points: 105512 + all_points: 105512 + rank: 5 + dominance: 2.1415270018621975 + created_at: 2022-02-24T12:00:05.000Z + profile_url: https://it70.tribals.it/game.php?screen=info_ally&id=31 + - rank_att: 1 + score_att: 669292167 + rank_def: 5 + score_def: 199124128 + rank_sup: 0 + score_sup: 0 + rank_total: 2 + score_total: 868416295 + _id: pl169-csa + id: 28 + server_key: pl169 + name: Konfederacja + tag: CSA. + num_members: 66 + num_villages: 14480 + points: 114277979 + all_points: 143350058 + rank: 1 + dominance: 29.76912481240106 + created_at: 2021-09-02T23:01:13.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_ally&id=28 + - rank_att: 3 + score_att: 358150686 + rank_def: 3 + score_def: 331975367 + rank_sup: 0 + score_sup: 0 + rank_total: 4 + score_total: 690126053 + _id: pl169-csa-jr + id: 2 + server_key: pl169 + name: KONFEDERACJA JUNIOR + tag: CSA.JR + num_members: 53 + num_villages: 10752 + points: 97684589 + all_points: 105576587 + rank: 2 + dominance: 22.10480870047902 + created_at: 2021-09-02T18:01:08.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_ally&id=2 + - rank_att: 2 + score_att: 513902873 + rank_def: 6 + score_def: 177660231 + rank_sup: 0 + score_sup: 0 + rank_total: 3 + score_total: 691563104 + _id: pl169-kuzyni + id: 27 + server_key: pl169 + name: Spoceni Kuzyni + tag: KUZYNI + num_members: 52 + num_villages: 10152 + points: 91951132 + all_points: 97812820 + rank: 3 + dominance: 20.87128142924693 + created_at: 2021-09-02T22:00:37.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_ally&id=27 + - rank_att: 4 + score_att: 228636414 + rank_def: 1 + score_def: 865423891 + rank_sup: 0 + score_sup: 0 + rank_total: 1 + score_total: 1094060305 + _id: pl169-lpk + id: 1 + server_key: pl169 + name: Łapanka + tag: ŁPK + num_members: 61 + num_villages: 3471 + points: 28417793 + all_points: 30608034 + rank: 4 + dominance: 7.13595526407763 + created_at: 2021-09-02T18:01:08.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_ally&id=1 + - rank_att: 5 + score_att: 163168564 + rank_def: 13 + score_def: 60229489 + rank_sup: 0 + score_sup: 0 + rank_total: 7 + score_total: 223398053 + _id: pl169-tt + id: 122 + server_key: pl169 + name: TIP TOP + tag: TT. + num_members: 23 + num_villages: 908 + points: 8188040 + all_points: 8188040 + rank: 6 + dominance: 1.8667379371312267 + created_at: 2021-09-08T02:00:58.000Z + profile_url: https://pl169.plemiona.pl/game.php?screen=info_ally&id=122 diff --git a/internal/app/service_tribe.go b/internal/app/service_tribe.go index ac100a8..bda504f 100644 --- a/internal/app/service_tribe.go +++ b/internal/app/service_tribe.go @@ -7,12 +7,19 @@ import ( "gitea.dwysokinski.me/twhelp/corev3/internal/domain" ) +type TribeRepository interface { + CreateOrUpdate(ctx context.Context, params ...domain.CreateTribeParams) error + List(ctx context.Context, params domain.ListTribesParams) (domain.Tribes, error) + Delete(ctx context.Context, serverKey string, ids ...int) error +} + type TribeService struct { + repo TribeRepository twSvc TWService } -func NewTribeService(twSvc TWService) *TribeService { - return &TribeService{twSvc: twSvc} +func NewTribeService(repo TribeRepository, twSvc TWService) *TribeService { + return &TribeService{repo: repo, twSvc: twSvc} } func (svc *TribeService) Sync(ctx context.Context, payload domain.ServerSyncedEventPayload) error { diff --git a/internal/domain/base_server.go b/internal/domain/base_server.go index 4f9532a..23572c5 100644 --- a/internal/domain/base_server.go +++ b/internal/domain/base_server.go @@ -26,7 +26,7 @@ func NewBaseServer(key string, u *url.URL, open bool) (BaseServer, error) { return BaseServer{}, ValidationError{ Model: baseServerModelName, Field: "url", - Err: ErrNotNil, + Err: ErrNil, } } @@ -37,20 +37,20 @@ func NewBaseServer(key string, u *url.URL, open bool) (BaseServer, error) { }, nil } -func (b BaseServer) Key() string { - return b.key +func (s BaseServer) Key() string { + return s.key } -func (b BaseServer) URL() *url.URL { - return b.url +func (s BaseServer) URL() *url.URL { + return s.url } -func (b BaseServer) Open() bool { - return b.open +func (s BaseServer) Open() bool { + return s.open } -func (b BaseServer) IsZero() bool { - return b == BaseServer{} +func (s BaseServer) IsZero() bool { + return s == BaseServer{} } type BaseServers []BaseServer diff --git a/internal/domain/base_server_test.go b/internal/domain/base_server_test.go index b3692ac..a53147c 100644 --- a/internal/domain/base_server_test.go +++ b/internal/domain/base_server_test.go @@ -47,7 +47,7 @@ func TestNewBaseServer(t *testing.T) { expectedErr: domain.ValidationError{ Model: "BaseServer", Field: "url", - Err: domain.ErrNotNil, + Err: domain.ErrNil, }, }, } @@ -108,12 +108,12 @@ func TestBaseServers_FilterOutSpecial(t *testing.T) { } res := servers.FilterOutSpecial(special) - assert.Len(t, res, len(servers)-len(special)) for _, s := range servers { if slices.ContainsFunc(special, func(server domain.Server) bool { return server.Key() == s.Key() }) { - return + assert.NotContains(t, res, s) + continue } assert.Contains(t, res, s) diff --git a/internal/domain/base_tribe.go b/internal/domain/base_tribe.go index 0c183b2..f2d9456 100644 --- a/internal/domain/base_tribe.go +++ b/internal/domain/base_tribe.go @@ -1,6 +1,9 @@ package domain -import "net/url" +import ( + "math" + "net/url" +) type BaseTribe struct { id int @@ -30,96 +33,70 @@ func NewBaseTribe( od OpponentsDefeated, profileURL *url.URL, ) (BaseTribe, error) { - if id < 1 { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "id", - Err: MinGreaterEqualError{ - Min: 1, - Current: id, - }, + Err: err, } } - if l := len(name); l < tribeNameMinLength || l > tribeNameMaxLength { + if err := validateStringLen(name, tribeNameMinLength, tribeNameMaxLength); err != nil { return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "name", - Err: LenOutOfRangeError{ - Min: tribeNameMinLength, - Max: tribeNameMaxLength, - Current: l, - }, + Err: err, } } // we convert tag to []rune to get the correct length // e.g. for tags such as Орловы, which takes 12 bytes rather than 6 // explanation: https://golangbyexample.com/number-characters-string-golang/ - if l := len([]rune(tag)); l < tribeTagMinLength || l > tribeTagMaxLength { + if err := validateSliceLen([]rune(tag), tribeTagMinLength, tribeTagMaxLength); err != nil { return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "tag", - Err: LenOutOfRangeError{ - Min: tribeTagMinLength, - Max: tribeTagMaxLength, - Current: l, - }, + Err: err, } } - if numMembers < 0 { + if err := validateIntInRange(numMembers, 0, math.MaxInt); err != nil { return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "numMembers", - Err: MinGreaterEqualError{ - Min: 0, - Current: numMembers, - }, + Err: err, } } - if numVillages < 0 { + if err := validateIntInRange(numVillages, 0, math.MaxInt); err != nil { return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "numVillages", - Err: MinGreaterEqualError{ - Min: 0, - Current: numVillages, - }, + Err: err, } } - if points < 0 { + if err := validateIntInRange(points, 0, math.MaxInt); err != nil { return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "points", - Err: MinGreaterEqualError{ - Min: 0, - Current: points, - }, + Err: err, } } - if allPoints < 0 { + if err := validateIntInRange(allPoints, 0, math.MaxInt); err != nil { return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "allPoints", - Err: MinGreaterEqualError{ - Min: 0, - Current: allPoints, - }, + Err: err, } } - if rank < 0 { + if err := validateIntInRange(rank, 0, math.MaxInt); err != nil { return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "rank", - Err: MinGreaterEqualError{ - Min: 0, - Current: rank, - }, + Err: err, } } @@ -127,7 +104,7 @@ func NewBaseTribe( return BaseTribe{}, ValidationError{ Model: baseTribeModelName, Field: "profileURL", - Err: ErrNotNil, + Err: ErrNil, } } @@ -145,44 +122,48 @@ func NewBaseTribe( }, nil } -func (b BaseTribe) ID() int { - return b.id +func (t BaseTribe) ID() int { + return t.id } -func (b BaseTribe) Name() string { - return b.name +func (t BaseTribe) Name() string { + return t.name } -func (b BaseTribe) Tag() string { - return b.tag +func (t BaseTribe) Tag() string { + return t.tag } -func (b BaseTribe) NumMembers() int { - return b.numMembers +func (t BaseTribe) NumMembers() int { + return t.numMembers } -func (b BaseTribe) NumVillages() int { - return b.numVillages +func (t BaseTribe) NumVillages() int { + return t.numVillages } -func (b BaseTribe) Points() int { - return b.points +func (t BaseTribe) Points() int { + return t.points } -func (b BaseTribe) AllPoints() int { - return b.allPoints +func (t BaseTribe) AllPoints() int { + return t.allPoints } -func (b BaseTribe) Rank() int { - return b.rank +func (t BaseTribe) Rank() int { + return t.rank } -func (b BaseTribe) OD() OpponentsDefeated { - return b.od +func (t BaseTribe) OD() OpponentsDefeated { + return t.od } -func (b BaseTribe) ProfileURL() *url.URL { - return b.profileURL +func (t BaseTribe) ProfileURL() *url.URL { + return t.profileURL +} + +func (t BaseTribe) IsZero() bool { + return t == BaseTribe{} } type BaseTribes []BaseTribe diff --git a/internal/domain/base_tribe_test.go b/internal/domain/base_tribe_test.go index 6e76ae2..ba4e801 100644 --- a/internal/domain/base_tribe_test.go +++ b/internal/domain/base_tribe_test.go @@ -315,7 +315,7 @@ func TestNewBaseTribe(t *testing.T) { expectedErr: domain.ValidationError{ Model: "BaseTribe", Field: "profileURL", - Err: domain.ErrNotNil, + Err: domain.ErrNil, }, }, } diff --git a/internal/domain/domaintest/base_tribe.go b/internal/domain/domaintest/base_tribe.go index 2d2ab73..8b39d9d 100644 --- a/internal/domain/domaintest/base_tribe.go +++ b/internal/domain/domaintest/base_tribe.go @@ -9,18 +9,24 @@ import ( ) type BaseTribeConfig struct { - ID int - Tag string - OD domain.OpponentsDefeated + ID int + Tag string + OD domain.OpponentsDefeated + NumVillages int + AllPoints int + Rank int } func NewBaseTribe(tb TestingTB, opts ...func(cfg *BaseTribeConfig)) domain.BaseTribe { tb.Helper() cfg := &BaseTribeConfig{ - ID: RandID(), - Tag: RandTribeTag(), - OD: NewOpponentsDefeated(tb), + ID: RandID(), + Tag: RandTribeTag(), + OD: NewOpponentsDefeated(tb), + NumVillages: gofakeit.IntRange(1, 10000), + AllPoints: gofakeit.IntRange(1, 10000), + Rank: gofakeit.IntRange(1, 10000), } for _, opt := range opts { @@ -35,10 +41,10 @@ func NewBaseTribe(tb TestingTB, opts ...func(cfg *BaseTribeConfig)) domain.BaseT gofakeit.LetterN(50), cfg.Tag, gofakeit.IntRange(1, 10000), + cfg.NumVillages, gofakeit.IntRange(1, 10000), - gofakeit.IntRange(1, 10000), - gofakeit.IntRange(1, 10000), - gofakeit.IntRange(1, 10000), + cfg.AllPoints, + cfg.Rank, cfg.OD, u, ) diff --git a/internal/domain/domaintest/tribe.go b/internal/domain/domaintest/tribe.go index c4028b6..b915964 100644 --- a/internal/domain/domaintest/tribe.go +++ b/internal/domain/domaintest/tribe.go @@ -1,7 +1,73 @@ package domaintest -import "github.com/brianvoe/gofakeit/v6" +import ( + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/require" +) func RandTribeTag() string { return gofakeit.LetterN(5) } + +type TribeConfig struct { + ID int + ServerKey string + Tag string + OD domain.OpponentsDefeated + BestRank int + BestRankAt time.Time + MostPoints int + MostPointsAt time.Time + MostVillages int + MostVillagesAt time.Time +} + +func NewTribe(tb TestingTB, opts ...func(cfg *TribeConfig)) domain.Tribe { + tb.Helper() + + cfg := &TribeConfig{ + ID: RandID(), + ServerKey: RandServerKey(), + Tag: RandTribeTag(), + OD: NewOpponentsDefeated(tb), + BestRank: gofakeit.IntRange(1, 10000), + BestRankAt: time.Now(), + MostPoints: gofakeit.IntRange(1, 10000), + MostPointsAt: time.Now(), + MostVillages: gofakeit.IntRange(1, 10000), + MostVillagesAt: time.Now(), + } + + for _, opt := range opts { + opt(cfg) + } + + t, err := domain.UnmarshalTribeFromDatabase( + cfg.ID, + cfg.ServerKey, + gofakeit.LetterN(50), + cfg.Tag, + gofakeit.IntRange(1, 10000), + gofakeit.IntRange(1, 10000), + gofakeit.IntRange(1, 10000), + gofakeit.IntRange(1, 10000), + gofakeit.IntRange(1, 10000), + cfg.OD, + gofakeit.URL(), + gofakeit.Float64Range(0.1, 99.9), + cfg.BestRank, + cfg.BestRankAt, + cfg.MostPoints, + cfg.MostPointsAt, + cfg.MostVillages, + cfg.MostVillagesAt, + time.Now(), + time.Time{}, + ) + require.NoError(tb, err) + + return t +} diff --git a/internal/domain/opponents_defeated.go b/internal/domain/opponents_defeated.go index 96b8a77..89e06df 100644 --- a/internal/domain/opponents_defeated.go +++ b/internal/domain/opponents_defeated.go @@ -1,5 +1,7 @@ package domain +import "math" + type OpponentsDefeated struct { rankAtt int scoreAtt int @@ -23,91 +25,67 @@ func NewOpponentsDefeated( rankTotal int, scoreTotal int, ) (OpponentsDefeated, error) { - if rankAtt < 0 { + if err := validateIntInRange(rankAtt, 0, math.MaxInt); err != nil { return OpponentsDefeated{}, ValidationError{ Model: opponentsDefeatedModelName, Field: "rankAtt", - Err: MinGreaterEqualError{ - Min: 0, - Current: rankAtt, - }, + Err: err, } } - if scoreAtt < 0 { + if err := validateIntInRange(scoreAtt, 0, math.MaxInt); err != nil { return OpponentsDefeated{}, ValidationError{ Model: opponentsDefeatedModelName, Field: "scoreAtt", - Err: MinGreaterEqualError{ - Min: 0, - Current: scoreAtt, - }, + Err: err, } } - if rankDef < 0 { + if err := validateIntInRange(rankDef, 0, math.MaxInt); err != nil { return OpponentsDefeated{}, ValidationError{ Model: opponentsDefeatedModelName, Field: "rankDef", - Err: MinGreaterEqualError{ - Min: 0, - Current: rankDef, - }, + Err: err, } } - if scoreDef < 0 { + if err := validateIntInRange(scoreDef, 0, math.MaxInt); err != nil { return OpponentsDefeated{}, ValidationError{ Model: opponentsDefeatedModelName, Field: "scoreDef", - Err: MinGreaterEqualError{ - Min: 0, - Current: scoreDef, - }, + Err: err, } } - if rankSup < 0 { + if err := validateIntInRange(rankSup, 0, math.MaxInt); err != nil { return OpponentsDefeated{}, ValidationError{ Model: opponentsDefeatedModelName, Field: "rankSup", - Err: MinGreaterEqualError{ - Min: 0, - Current: rankSup, - }, + Err: err, } } - if scoreSup < 0 { + if err := validateIntInRange(scoreSup, 0, math.MaxInt); err != nil { return OpponentsDefeated{}, ValidationError{ Model: opponentsDefeatedModelName, Field: "scoreSup", - Err: MinGreaterEqualError{ - Min: 0, - Current: scoreSup, - }, + Err: err, } } - if rankTotal < 0 { + if err := validateIntInRange(rankTotal, 0, math.MaxInt); err != nil { return OpponentsDefeated{}, ValidationError{ Model: opponentsDefeatedModelName, Field: "rankTotal", - Err: MinGreaterEqualError{ - Min: 0, - Current: rankTotal, - }, + Err: err, } } - if scoreTotal < 0 { + if err := validateIntInRange(scoreTotal, 0, math.MaxInt); err != nil { return OpponentsDefeated{}, ValidationError{ Model: opponentsDefeatedModelName, Field: "scoreTotal", - Err: MinGreaterEqualError{ - Min: 0, - Current: scoreTotal, - }, + Err: err, } } diff --git a/internal/domain/server.go b/internal/domain/server.go index 6cf4522..434ac63 100644 --- a/internal/domain/server.go +++ b/internal/domain/server.go @@ -1,8 +1,8 @@ package domain import ( - "errors" "fmt" + "math" "net/url" "slices" "time" @@ -37,6 +37,8 @@ type Server struct { ennoblementDataSyncedAt time.Time } +const serverModelName = "Server" + // UnmarshalServerFromDatabase unmarshals Server from the database. // // It should be used only for unmarshalling from the database! @@ -65,16 +67,28 @@ func UnmarshalServerFromDatabase( ennoblementDataSyncedAt time.Time, ) (Server, error) { if key == "" { - return Server{}, errors.New("key can't be blank") + return Server{}, ValidationError{ + Model: serverModelName, + Field: "key", + Err: ErrRequired, + } } if versionCode == "" { - return Server{}, errors.New("version code can't be blank") + return Server{}, ValidationError{ + Model: serverModelName, + Field: "versionCode", + Err: ErrRequired, + } } u, err := parseURL(rawURL) if err != nil { - return Server{}, err + return Server{}, ValidationError{ + Model: serverModelName, + Field: "url", + Err: err, + } } return Server{ @@ -186,6 +200,14 @@ func (s Server) EnnoblementDataSyncedAt() time.Time { return s.ennoblementDataSyncedAt } +func (s Server) Base() BaseServer { + return BaseServer{ + key: s.key, + url: s.url, + open: s.open, + } +} + type Servers []Server // Close finds all servers with Server.Open returning true that are not in the given slice with open servers @@ -398,25 +420,11 @@ func (params *ListServersParams) Limit() int { } func (params *ListServersParams) SetLimit(limit int) error { - if limit <= 0 { + if err := validateIntInRange(limit, 1, ServerListMaxLimit); err != nil { return ValidationError{ Model: listServersParamsModelName, Field: "limit", - Err: MinGreaterEqualError{ - Min: 1, - Current: limit, - }, - } - } - - if limit > ServerListMaxLimit { - return ValidationError{ - Model: listServersParamsModelName, - Field: "limit", - Err: MaxLessEqualError{ - Max: ServerListMaxLimit, - Current: limit, - }, + Err: err, } } @@ -430,14 +438,11 @@ func (params *ListServersParams) Offset() int { } func (params *ListServersParams) SetOffset(offset int) error { - if offset < 0 { + if err := validateIntInRange(offset, 0, math.MaxInt); err != nil { return ValidationError{ Model: listServersParamsModelName, Field: "offset", - Err: MinGreaterEqualError{ - Min: 0, - Current: offset, - }, + Err: err, } } diff --git a/internal/domain/server_message_payloads.go b/internal/domain/server_message_payloads.go index 8a77428..bf1a134 100644 --- a/internal/domain/server_message_payloads.go +++ b/internal/domain/server_message_payloads.go @@ -1,8 +1,6 @@ package domain import ( - "errors" - "fmt" "net/url" ) @@ -11,13 +9,23 @@ type SyncServersCmdPayload struct { url *url.URL } +const syncServersCmdPayloadModelName = "SyncServersCmdPayload" + func NewSyncServersCmdPayload(versionCode string, u *url.URL) (SyncServersCmdPayload, error) { if versionCode == "" { - return SyncServersCmdPayload{}, errors.New("version code can't be blank") + return SyncServersCmdPayload{}, ValidationError{ + Model: syncServersCmdPayloadModelName, + Field: "versionCode", + Err: ErrRequired, + } } if u == nil { - return SyncServersCmdPayload{}, errors.New("url can't be nil") + return SyncServersCmdPayload{}, ValidationError{ + Model: syncServersCmdPayloadModelName, + Field: "url", + Err: ErrNil, + } } return SyncServersCmdPayload{versionCode: versionCode, url: u}, nil @@ -37,17 +45,31 @@ type ServerSyncedEventPayload struct { versionCode string } +const serverSyncedEventPayloadModelName = "ServerSyncedEventPayload" + func NewServerSyncedEventPayload(key string, u *url.URL, versionCode string) (ServerSyncedEventPayload, error) { if key == "" { - return ServerSyncedEventPayload{}, errors.New("key can't be blank") + return ServerSyncedEventPayload{}, ValidationError{ + Model: serverSyncedEventPayloadModelName, + Field: "key", + Err: ErrRequired, + } } if versionCode == "" { - return ServerSyncedEventPayload{}, errors.New("version code can't be blank") + return ServerSyncedEventPayload{}, ValidationError{ + Model: serverSyncedEventPayloadModelName, + Field: "versionCode", + Err: ErrRequired, + } } if u == nil { - return ServerSyncedEventPayload{}, errors.New("url can't be nil") + return ServerSyncedEventPayload{}, ValidationError{ + Model: serverSyncedEventPayloadModelName, + Field: "url", + Err: ErrNil, + } } return ServerSyncedEventPayload{ @@ -58,22 +80,15 @@ func NewServerSyncedEventPayload(key string, u *url.URL, versionCode string) (Se } func NewServerSyncedEventPayloads(servers BaseServers, versionCode string) ([]ServerSyncedEventPayload, error) { - if versionCode == "" { - return nil, errors.New("version code can't be blank") - } - res := make([]ServerSyncedEventPayload, 0, len(servers)) - for i, s := range servers { - if s.IsZero() { - return nil, fmt.Errorf("servers[%d] is an empty struct", i) + for _, s := range servers { + payload, err := NewServerSyncedEventPayload(s.Key(), s.URL(), versionCode) + if err != nil { + return nil, err } - res = append(res, ServerSyncedEventPayload{ - key: s.Key(), - url: s.URL(), - versionCode: versionCode, - }) + res = append(res, payload) } return res, nil diff --git a/internal/domain/server_test.go b/internal/domain/server_test.go index cfeebdb..07f646e 100644 --- a/internal/domain/server_test.go +++ b/internal/domain/server_test.go @@ -37,17 +37,20 @@ func TestServers_Close(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, res) for _, s := range servers { - if !s.Open() || slices.ContainsFunc(open, func(server domain.BaseServer) bool { - return server.Key() == s.Key() - }) { - continue - } - - assert.Contains(t, res, domaintest.NewBaseServer(t, func(cfg *domaintest.BaseServerConfig) { + expected := domaintest.NewBaseServer(t, func(cfg *domaintest.BaseServerConfig) { cfg.Key = s.Key() cfg.URL = s.URL() cfg.Open = false - })) + }) + + if !s.Open() || slices.ContainsFunc(open, func(server domain.BaseServer) bool { + return server.Key() == s.Key() + }) { + assert.NotContains(t, res, expected) + continue + } + + assert.Contains(t, res, expected) } } diff --git a/internal/domain/tribe.go b/internal/domain/tribe.go index 937de6c..14c5ef6 100644 --- a/internal/domain/tribe.go +++ b/internal/domain/tribe.go @@ -1,7 +1,10 @@ package domain import ( + "fmt" + "math" "net/url" + "slices" "time" ) @@ -35,6 +38,75 @@ type Tribe struct { deletedAt time.Time } +const tribeModelName = "Tribe" + +// UnmarshalTribeFromDatabase unmarshals Tribe from the database. +// +// It should be used only for unmarshalling from the database! +// You can't use UnmarshalTribeFromDatabase as constructor - It may put domain into the invalid state! +func UnmarshalTribeFromDatabase( + id int, + serverKey string, + name string, + tag string, + numMembers int, + numVillages int, + points int, + allPoints int, + rank int, + od OpponentsDefeated, + rawProfileURL string, + dominance float64, + bestRank int, + bestRankAt time.Time, + mostPoints int, + mostPointsAt time.Time, + mostVillages int, + mostVillagesAt time.Time, + createdAt time.Time, + deletedAt time.Time, +) (Tribe, error) { + if err := validateIntInRange(id, 1, math.MaxInt); err != nil { + return Tribe{}, ValidationError{ + Model: tribeModelName, + Field: "id", + Err: err, + } + } + + profileURL, err := parseURL(rawProfileURL) + if err != nil { + return Tribe{}, ValidationError{ + Model: tribeModelName, + Field: "profileURL", + Err: err, + } + } + + return Tribe{ + id: id, + serverKey: serverKey, + name: name, + tag: tag, + numMembers: numMembers, + numVillages: numVillages, + points: points, + allPoints: allPoints, + rank: rank, + od: od, + profileURL: profileURL, + dominance: dominance, + bestRank: bestRank, + bestRankAt: bestRankAt, + mostPoints: mostPoints, + mostPointsAt: mostPointsAt, + mostVillages: mostVillages, + mostVillagesAt: mostVillagesAt, + createdAt: createdAt, + deletedAt: deletedAt, + }, nil +} + func (t Tribe) ID() int { return t.id } @@ -114,3 +186,276 @@ func (t Tribe) CreatedAt() time.Time { func (t Tribe) DeletedAt() time.Time { return t.deletedAt } + +func (t Tribe) IsDeleted() bool { + return !t.deletedAt.IsZero() +} + +func (t Tribe) Base() BaseTribe { + return BaseTribe{ + id: t.id, + name: t.name, + tag: t.tag, + numMembers: t.numMembers, + numVillages: t.numVillages, + points: t.points, + allPoints: t.allPoints, + rank: t.rank, + od: t.od, + profileURL: t.profileURL, + } +} + +type Tribes []Tribe + +type CreateTribeParams struct { + base BaseTribe + serverKey string + bestRank int + bestRankAt time.Time + mostPoints int + mostPointsAt time.Time + mostVillages int + mostVillagesAt time.Time +} + +const createTribeParamsModelName = "CreateTribeParams" + +//nolint:gocyclo +func NewCreateTribeParams(serverKey string, tribes BaseTribes, storedTribes Tribes) ([]CreateTribeParams, error) { + if err := validateServerKey(serverKey); err != nil { + return nil, ValidationError{ + Model: createTribeParamsModelName, + Field: "serverKey", + Err: err, + } + } + + params := make([]CreateTribeParams, 0, len(tribes)) + + for i, t := range tribes { + if t.IsZero() { + return nil, fmt.Errorf("tribes[%d] is an empty struct", i) + } + + var old Tribe + if oldIdx := slices.IndexFunc(storedTribes, func(old Tribe) bool { + return old.ID() == t.ID() && old.ServerKey() == serverKey + }); oldIdx >= 0 { + old = storedTribes[oldIdx] + } + + p := CreateTribeParams{ + base: t, + serverKey: serverKey, + bestRank: old.bestRank, + bestRankAt: old.bestRankAt, + mostPoints: old.mostPoints, + mostPointsAt: old.mostPointsAt, + mostVillages: old.mostVillages, + mostVillagesAt: old.mostVillagesAt, + } + + if t.AllPoints() > p.MostPoints() || old.ID() <= 0 { + p.mostPoints = t.AllPoints() + p.mostPointsAt = time.Now() + } + + if t.NumVillages() > p.MostVillages() || old.ID() <= 0 { + p.mostVillages = t.NumVillages() + p.mostVillagesAt = time.Now() + } + + if t.Rank() < p.BestRank() || old.ID() <= 0 { + p.bestRank = t.Rank() + p.bestRankAt = time.Now() + } + + params = append(params, p) + } + + return params, nil +} + +func (params CreateTribeParams) Base() BaseTribe { + return params.base +} + +func (params CreateTribeParams) ServerKey() string { + return params.serverKey +} + +func (params CreateTribeParams) BestRank() int { + return params.bestRank +} + +func (params CreateTribeParams) BestRankAt() time.Time { + return params.bestRankAt +} + +func (params CreateTribeParams) MostPoints() int { + return params.mostPoints +} + +func (params CreateTribeParams) MostPointsAt() time.Time { + return params.mostPointsAt +} + +func (params CreateTribeParams) MostVillages() int { + return params.mostVillages +} + +func (params CreateTribeParams) MostVillagesAt() time.Time { + return params.mostVillagesAt +} + +type TribeSort uint8 + +const ( + TribeSortIDASC TribeSort = iota + 1 + TribeSortIDDESC + TribeSortServerKeyASC + TribeSortServerKeyDESC +) + +const TribeListMaxLimit = 200 + +type ListTribesParams struct { + ids []int + idGT NullInt + serverKeys []string + deleted NullBool + sort []TribeSort + limit int + offset int +} + +const listTribesParamsModelName = "ListTribesParams" + +func NewListTribesParams() ListTribesParams { + return ListTribesParams{ + sort: []TribeSort{ + TribeSortServerKeyASC, + TribeSortIDASC, + }, + limit: TribeListMaxLimit, + } +} + +func (params *ListTribesParams) IDs() []int { + return params.ids +} + +func (params *ListTribesParams) SetIDs(ids []int) error { + for i, id := range ids { + if err := validateIntInRange(id, 0, math.MaxInt); err != nil { + return SliceElementValidationError{ + Model: listTribesParamsModelName, + Field: "ids", + Index: i, + Err: err, + } + } + } + + params.ids = ids + + return nil +} + +func (params *ListTribesParams) IDGT() NullInt { + return params.idGT +} + +func (params *ListTribesParams) SetIDGT(idGT NullInt) error { + if idGT.Valid { + if err := validateIntInRange(idGT.Value, 0, math.MaxInt); err != nil { + return ValidationError{ + Model: listTribesParamsModelName, + Field: "idGT", + Err: err, + } + } + } + + params.idGT = idGT + + return nil +} + +func (params *ListTribesParams) ServerKeys() []string { + return params.serverKeys +} + +func (params *ListTribesParams) SetServerKeys(serverKeys []string) error { + params.serverKeys = serverKeys + return nil +} + +func (params *ListTribesParams) Deleted() NullBool { + return params.deleted +} + +func (params *ListTribesParams) SetDeleted(deleted NullBool) error { + params.deleted = deleted + return nil +} + +func (params *ListTribesParams) Sort() []TribeSort { + return params.sort +} + +const ( + tribeSortMinLength = 1 + tribeSortMaxLength = 2 +) + +func (params *ListTribesParams) SetSort(sort []TribeSort) error { + if err := validateSliceLen(sort, tribeSortMinLength, tribeSortMaxLength); err != nil { + return ValidationError{ + Model: listTribesParamsModelName, + Field: "sort", + Err: err, + } + } + + params.sort = sort + + return nil +} + +func (params *ListTribesParams) Limit() int { + return params.limit +} + +func (params *ListTribesParams) SetLimit(limit int) error { + if err := validateIntInRange(limit, 1, TribeListMaxLimit); err != nil { + return ValidationError{ + Model: listTribesParamsModelName, + Field: "limit", + Err: err, + } + } + + params.limit = limit + + return nil +} + +func (params *ListTribesParams) Offset() int { + return params.offset +} + +func (params *ListTribesParams) SetOffset(offset int) error { + if err := validateIntInRange(offset, 0, math.MaxInt); err != nil { + return ValidationError{ + Model: listTribesParamsModelName, + Field: "offset", + Err: err, + } + } + + params.offset = offset + + return nil +} diff --git a/internal/domain/tribe_test.go b/internal/domain/tribe_test.go new file mode 100644 index 0000000..7fb962e --- /dev/null +++ b/internal/domain/tribe_test.go @@ -0,0 +1,419 @@ +package domain_test + +import ( + "fmt" + "math" + "slices" + "testing" + "time" + + "gitea.dwysokinski.me/twhelp/corev3/internal/domain" + "gitea.dwysokinski.me/twhelp/corev3/internal/domain/domaintest" + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCreateTribeParams(t *testing.T) { + t.Parallel() + + now := time.Now() + + server := domaintest.NewServer(t) + + tribes := domain.BaseTribes{ + domaintest.NewBaseTribe(t), + domaintest.NewBaseTribe(t), + domaintest.NewBaseTribe(t), + } + + storedTribes := domain.Tribes{ + domaintest.NewTribe(t, func(cfg *domaintest.TribeConfig) { + cfg.ID = tribes[0].ID() + cfg.ServerKey = server.Key() + cfg.BestRank = tribes[0].Rank() + 1 + cfg.BestRankAt = now.Add(-time.Hour) + cfg.MostPoints = tribes[0].AllPoints() - 1 + cfg.MostPointsAt = now.Add(-time.Hour) + cfg.MostVillages = tribes[0].NumVillages() - 1 + cfg.MostVillagesAt = now.Add(-time.Hour) + }), + domaintest.NewTribe(t, func(cfg *domaintest.TribeConfig) { + cfg.ID = tribes[1].ID() + cfg.ServerKey = server.Key() + cfg.BestRank = tribes[1].Rank() - 1 + cfg.BestRankAt = now.Add(-time.Hour) + cfg.MostPoints = tribes[1].AllPoints() + 1 + cfg.MostPointsAt = now.Add(-time.Hour) + cfg.MostVillages = tribes[1].NumVillages() + 1 + cfg.MostVillagesAt = now.Add(-time.Hour) + }), + } + + expectedParams := []struct { + base domain.BaseTribe + bestRank int + bestRankAt time.Time + mostPoints int + mostPointsAt time.Time + mostVillages int + mostVillagesAt time.Time + }{ + { + base: tribes[0], + bestRank: tribes[0].Rank(), + bestRankAt: now, + mostPoints: tribes[0].AllPoints(), + mostPointsAt: now, + mostVillages: tribes[0].NumVillages(), + mostVillagesAt: now, + }, + { + base: tribes[1], + bestRank: storedTribes[1].BestRank(), + bestRankAt: storedTribes[1].BestRankAt(), + mostPoints: storedTribes[1].MostPoints(), + mostPointsAt: storedTribes[1].MostPointsAt(), + mostVillages: storedTribes[1].MostVillages(), + mostVillagesAt: storedTribes[1].MostVillagesAt(), + }, + { + base: tribes[2], + bestRank: tribes[2].Rank(), + bestRankAt: now, + mostPoints: tribes[2].AllPoints(), + mostPointsAt: now, + mostVillages: tribes[2].NumVillages(), + mostVillagesAt: now, + }, + } + + res, err := domain.NewCreateTribeParams(server.Key(), tribes, storedTribes) + require.NoError(t, err) + assert.Len(t, res, len(expectedParams)) + for i, expected := range expectedParams { + idx := slices.IndexFunc(res, func(params domain.CreateTribeParams) bool { + return params.Base().ID() == expected.base.ID() + }) + require.GreaterOrEqualf(t, idx, 0, "expectedParams[%d] not found", i) + + params := res[idx] + + assert.Equalf(t, expected.base, params.Base(), "expectedParams[%d]", i) + assert.Equalf(t, expected.bestRank, params.BestRank(), "expectedParams[%d]", i) + assert.WithinDurationf(t, expected.bestRankAt, params.BestRankAt(), time.Minute, "expectedParams[%d]", i) + assert.Equalf(t, expected.mostPoints, params.MostPoints(), "expectedParams[%d]", i) + assert.WithinDurationf(t, expected.mostPointsAt, params.MostPointsAt(), time.Minute, "expectedParams[%d]", i) + assert.Equalf(t, expected.mostVillages, params.MostVillages(), "expectedParams[%d]", i) + assert.WithinDurationf(t, expected.mostVillagesAt, params.MostVillagesAt(), time.Minute, "expectedParams[%d]", i) + } +} + +func TestListTribesParams_SetIDs(t *testing.T) { + t.Parallel() + + type args struct { + ids []int + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + ids: []int{ + gofakeit.IntRange(0, math.MaxInt), + gofakeit.IntRange(0, math.MaxInt), + gofakeit.IntRange(0, math.MaxInt), + }, + }, + }, + { + name: "ERR: value < 0", + args: args{ + ids: []int{ + gofakeit.IntRange(0, math.MaxInt), + gofakeit.IntRange(0, math.MaxInt), + gofakeit.IntRange(0, math.MaxInt), + -1, + gofakeit.IntRange(0, math.MaxInt), + }, + }, + expectedErr: domain.SliceElementValidationError{ + Model: "ListTribesParams", + Field: "ids", + Index: 3, + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListTribesParams() + + require.ErrorIs(t, params.SetIDs(tt.args.ids), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.ids, params.IDs()) + }) + } +} + +func TestListTribesParams_SetIDGT(t *testing.T) { + t.Parallel() + + type args struct { + idGT domain.NullInt + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + idGT: domain.NullInt{ + Value: gofakeit.IntRange(0, math.MaxInt), + Valid: true, + }, + }, + }, + { + name: "ERR: value < 0", + args: args{ + idGT: domain.NullInt{ + Value: -1, + Valid: true, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "idGT", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListTribesParams() + + require.ErrorIs(t, params.SetIDGT(tt.args.idGT), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.idGT, params.IDGT()) + }) + } +} + +func TestListTribesParams_SetSort(t *testing.T) { + t.Parallel() + + type args struct { + sort []domain.TribeSort + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + sort: []domain.TribeSort{ + domain.TribeSortIDASC, + domain.TribeSortServerKeyASC, + }, + }, + }, + { + name: "ERR: len(sort) < 1", + args: args{ + sort: nil, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 2, + Current: 0, + }, + }, + }, + { + name: "ERR: len(sort) > 2", + args: args{ + sort: []domain.TribeSort{ + domain.TribeSortIDASC, + domain.TribeSortServerKeyASC, + domain.TribeSortServerKeyDESC, + }, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "sort", + Err: domain.LenOutOfRangeError{ + Min: 1, + Max: 2, + Current: 3, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListTribesParams() + + require.ErrorIs(t, params.SetSort(tt.args.sort), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.sort, params.Sort()) + }) + } +} + +func TestListTribesParams_SetLimit(t *testing.T) { + t.Parallel() + + type args struct { + limit int + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + limit: domain.TribeListMaxLimit, + }, + }, + { + name: "ERR: limit < 1", + args: args{ + limit: 0, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "limit", + Err: domain.MinGreaterEqualError{ + Min: 1, + Current: 0, + }, + }, + }, + { + name: fmt.Sprintf("ERR: limit > %d", domain.TribeListMaxLimit), + args: args{ + limit: domain.TribeListMaxLimit + 1, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "limit", + Err: domain.MaxLessEqualError{ + Max: domain.TribeListMaxLimit, + Current: domain.TribeListMaxLimit + 1, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListTribesParams() + + require.ErrorIs(t, params.SetLimit(tt.args.limit), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.limit, params.Limit()) + }) + } +} + +func TestListTribesParams_SetOffset(t *testing.T) { + t.Parallel() + + type args struct { + offset int + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + offset: 100, + }, + }, + { + name: "ERR: offset < 0", + args: args{ + offset: -1, + }, + expectedErr: domain.ValidationError{ + Model: "ListTribesParams", + Field: "offset", + Err: domain.MinGreaterEqualError{ + Min: 0, + Current: -1, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewListTribesParams() + + require.ErrorIs(t, params.SetOffset(tt.args.offset), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.Equal(t, tt.args.offset, params.Offset()) + }) + } +} diff --git a/internal/domain/validation.go b/internal/domain/validation.go index f3bd39a..e832c76 100644 --- a/internal/domain/validation.go +++ b/internal/domain/validation.go @@ -3,8 +3,7 @@ package domain import ( "errors" "fmt" - - "github.com/gosimple/slug" + "strconv" ) type ValidationError struct { @@ -41,22 +40,11 @@ func (e ValidationError) Code() ErrorCode { } func (e ValidationError) Slug() string { - s := "validation" - var domainErr Error if errors.As(e.Err, &domainErr) { - s = domainErr.Slug() + return domainErr.Slug() } - - if e.Field != "" { - s = slug.Make(e.Field) + "-" + s - } - - if e.Model != "" { - s = slug.Make(e.Model) + "-" + s - } - - return s + return "validation-failed" } func (e ValidationError) Params() map[string]any { @@ -71,6 +59,60 @@ func (e ValidationError) Unwrap() error { return e.Err } +type SliceElementValidationError struct { + Model string + Field string + Index int + Err error +} + +var _ ErrorWithParams = SliceElementValidationError{} + +func (e SliceElementValidationError) Error() string { + prefix := e.Model + + if e.Field != "" { + if len(prefix) > 0 { + prefix += "." + } + prefix += e.Field + "[" + strconv.Itoa(e.Index) + "]" + } + + if prefix != "" { + prefix += ": " + } + + return prefix + e.Err.Error() +} + +func (e SliceElementValidationError) Code() ErrorCode { + var domainErr Error + if errors.As(e.Err, &domainErr) { + return domainErr.Code() + } + return ErrorCodeIncorrectInput +} + +func (e SliceElementValidationError) Slug() string { + var domainErr Error + if errors.As(e.Err, &domainErr) { + return domainErr.Slug() + } + return "slice-element-validation-failed" +} + +func (e SliceElementValidationError) Params() map[string]any { + var withParams ErrorWithParams + if ok := errors.As(e.Err, &withParams); !ok { + return nil + } + return withParams.Params() +} + +func (e SliceElementValidationError) Unwrap() error { + return e.Err +} + type MinGreaterEqualError struct { Min int Current int @@ -151,10 +193,16 @@ func (e LenOutOfRangeError) Params() map[string]any { } } -var ErrNotNil error = simpleError{ +var ErrRequired error = simpleError{ + msg: "can't be blank", + code: ErrorCodeIncorrectInput, + slug: "required", +} + +var ErrNil error = simpleError{ msg: "must not be nil", code: ErrorCodeIncorrectInput, - slug: "not-nil", + slug: "nil", } func validateSliceLen[S ~[]E, E any](s S, min, max int) error { @@ -169,6 +217,18 @@ func validateSliceLen[S ~[]E, E any](s S, min, max int) error { return nil } +func validateStringLen(s string, min, max int) error { + if l := len(s); l > max || l < min { + return LenOutOfRangeError{ + Min: min, + Max: max, + Current: l, + } + } + + return nil +} + func validateServerKey(key string) error { if l := len(key); l < serverKeyMinLength || l > serverKeyMaxLength { return LenOutOfRangeError{ @@ -192,3 +252,21 @@ func validateVersionCode(code string) error { return nil } + +func validateIntInRange(current, min, max int) error { + if current < min { + return MinGreaterEqualError{ + Min: min, + Current: current, + } + } + + if current > max { + return MaxLessEqualError{ + Max: max, + Current: current, + } + } + + return nil +} diff --git a/internal/domain/version.go b/internal/domain/version.go index 358c3e6..bcd7574 100644 --- a/internal/domain/version.go +++ b/internal/domain/version.go @@ -1,7 +1,6 @@ package domain import ( - "errors" "net/url" ) @@ -17,6 +16,8 @@ type Version struct { timezone string } +var versionModelName = "Version" + // UnmarshalVersionFromDatabase unmarshals Version from the database. // // It should be used only for unmarshalling from the database! @@ -28,19 +29,35 @@ func UnmarshalVersionFromDatabase( timezone string, ) (Version, error) { if code == "" { - return Version{}, errors.New("code can't be blank") + return Version{}, ValidationError{ + Model: versionModelName, + Field: "code", + Err: ErrRequired, + } } if name == "" { - return Version{}, errors.New("name can't be blank") + return Version{}, ValidationError{ + Model: versionModelName, + Field: "name", + Err: ErrRequired, + } } if host == "" { - return Version{}, errors.New("host can't be blank") + return Version{}, ValidationError{ + Model: versionModelName, + Field: "host", + Err: ErrRequired, + } } if timezone == "" { - return Version{}, errors.New("timezone can't be blank") + return Version{}, ValidationError{ + Model: versionModelName, + Field: "timezone", + Err: ErrRequired, + } } return Version{