diff --git a/cmd/server/main.go b/cmd/server/main.go index 16c1ce7..ae4952b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "github.com/traPtitech/piscon-portal-v2/server/handler" dbrepo "github.com/traPtitech/piscon-portal-v2/server/repository/db" "github.com/traPtitech/piscon-portal-v2/server/services/oauth2" + "github.com/traPtitech/piscon-portal-v2/server/usecase" ) func main() { @@ -42,7 +43,8 @@ func main() { }, } repo := dbrepo.NewRepository(db) - handler, err := handler.New(repo, config) + useCase := usecase.New(repo) + handler, err := handler.New(useCase, repo, config) if err != nil { panic(err) } diff --git a/go.mod b/go.mod index 3ee8859..ac2c593 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,10 @@ require ( github.com/labstack/echo-contrib v0.17.2 github.com/labstack/echo/v4 v4.13.3 github.com/ogen-go/ogen v1.8.1 + github.com/samber/lo v1.47.0 github.com/stephenafamo/bob v0.29.0 + github.com/stretchr/testify v1.10.0 + github.com/testcontainers/testcontainers-go/modules/mysql v0.35.0 go.uber.org/mock v0.5.0 golang.org/x/oauth2 v0.25.0 google.golang.org/grpc v1.69.2 @@ -24,26 +27,73 @@ require ( ) require ( + dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aarondl/json v0.0.0-20221020222930-8b0db17ef1bf // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/go-jose/go-jose/v4 v4.0.2 // indirect + github.com/docker/docker v27.4.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/labstack/gommon v0.4.2 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect + github.com/magiconair/properties v1.8.9 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect github.com/segmentio/asm v1.2.0 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/stephenafamo/scan v0.6.1 // indirect + github.com/testcontainers/testcontainers-go v0.35.0 // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.9.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/net v0.33.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/otel v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/otel/trace v1.33.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.8.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index edebf50..ce1cba8 100644 --- a/go.sum +++ b/go.sum @@ -1,30 +1,64 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aarondl/json v0.0.0-20221020222930-8b0db17ef1bf h1:+edM69bH/X6JpYPmJYBRLanAMe1V5yRXYU3hHUovGcE= github.com/aarondl/json v0.0.0-20221020222930-8b0db17ef1bf/go.mod h1:FZqLhJSj2tg0ZN48GB1zvj00+ZYcHPqgsC7yzcgCq6k= github.com/aarondl/opt v0.0.0-20240623220848-083f18ab9536 h1:vhpjulzH5Tr4S3uJ3Y/9pNL481kPq5ERj13ceAW0/uE= github.com/aarondl/opt v0.0.0-20240623220848-083f18ab9536/go.mod h1:l4/5NZtYd/SIohsFhaJQQe+sPOTG22furpZ5FvcYOzk= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo= github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4= +github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-faster/jx v1.1.0 h1:ZsW3wD+snOdmTDy9eIVgQdjUpXRRV4rqW8NS3t+20bg= github.com/go-faster/jx v1.1.0/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg= -github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= -github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -41,29 +75,76 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/jaswdr/faker/v2 v2.3.3 h1:0mA+B5YGjqgpOPdDY/72d6pDv7Z/5t6F1XzIfkUfgC4= github.com/jaswdr/faker/v2 v2.3.3/go.mod h1:ROK8xwQV0hYOLDUtxCQgHGcl10jbVzIvqHxcIDdwY2Q= github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo-contrib v0.17.2 h1:K1zivqmtcC70X9VdBFdLomjPDEVHlrcAObqmuFj1c6w= github.com/labstack/echo-contrib v0.17.2/go.mod h1:NeDh3PX7j/u+jR4iuDt1zHmWZSCz9c/p9mxXcDpyS8E= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -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/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= +github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ogen-go/ogen v1.8.1 h1:7TZ+oIeLkcBiyl0qu0fHPrFUrGWDj3Fi/zKSWg2i2Tg= github.com/ogen-go/ogen v1.8.1/go.mod h1:2ShRm6u/nXUHuwdVKv2SeaG8enBKPKAE3kSbHwwFh6o= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc= github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494/go.mod h1:yipyliwI08eQ6XwDm1fEwKPdF/xdbkiHtrU+1Hg+vc4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stephenafamo/bob v0.29.0 h1:8Lfadt6ysRLt7TlNwxToW5fV7jdmgMO7a3pbPYiW9Z4= github.com/stephenafamo/bob v0.29.0/go.mod h1:0z9AeWTOTJmGsokEtQReTEJry4iI9J+SCyKMcr40mOo= github.com/stephenafamo/fakedb v0.0.0-20221230081958-0b86f816ed97 h1:XItoZNmhOih06TC02jK7l3wlpZ0XT/sPQYutDcGOQjg= @@ -73,45 +154,103 @@ github.com/stephenafamo/scan v0.6.1/go.mod h1:FhIUJ8pLNyex36xGFiazDJJ5Xry0UkAi+R github.com/stephenafamo/sqlparser v0.0.0-20241111104950-b04fa8a26c9c h1:JFga++XBnZG2xlnvQyHJkeBWZ9G9mGdtgvLeSRbp/BA= github.com/stephenafamo/sqlparser v0.0.0-20241111104950-b04fa8a26c9c/go.mod h1:4iveRk8mkzQZxDuK/W0MGLrGmu/igyDYWNDD4a6v0r0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= +github.com/testcontainers/testcontainers-go/modules/mysql v0.35.0 h1:9voGAf+1KxC0ck/XtrC/AUrkr74SSGpQRBp0O851B3Y= +github.com/testcontainers/testcontainers-go/modules/mysql v0.35.0/go.mod h1:rxKSkFpc5XZtG00prjqPfobuMgt5EpFEOrzZgYdOX0c= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= +github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= +github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= -go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= -go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= -go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= -go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= -go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +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/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= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +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/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= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 h1:fVoAXEKA4+yufmbdVYv+SE73+cPZbbbe8paLsHfkK+U= +google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53/go.mod h1:riSXTwQ4+nqmPGtobMFyW5FqVAmIs0St6VPp4Ug7CE4= google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= @@ -119,6 +258,11 @@ google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7 google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/server/domain/session.go b/server/domain/session.go index da4ab8d..9ecd4b9 100644 --- a/server/domain/session.go +++ b/server/domain/session.go @@ -3,12 +3,13 @@ package domain import ( "time" + "github.com/google/uuid" "github.com/traPtitech/piscon-portal-v2/server/utils/random" ) type Session struct { ID string - UserID string + UserID uuid.UUID ExpiresAt time.Time } @@ -16,7 +17,7 @@ func NewSessionID() string { return random.String(32) } -func NewSession(id, userID string, expiresAt time.Time) Session { +func NewSession(id string, userID uuid.UUID, expiresAt time.Time) Session { return Session{ ID: id, UserID: userID, diff --git a/server/domain/team.go b/server/domain/team.go new file mode 100644 index 0000000..bb1d6e1 --- /dev/null +++ b/server/domain/team.go @@ -0,0 +1,40 @@ +package domain + +import ( + "errors" + "slices" + "time" + + "github.com/google/uuid" +) + +const MaxTeamMembers = 3 + +type Team struct { + ID uuid.UUID + Name string + Members []User + CreatedAt time.Time +} + +func NewTeam(name string) Team { + return Team{ + ID: uuid.New(), + Name: name, + CreatedAt: time.Now(), + } +} + +func (t *Team) AddMember(user User) error { + if slices.ContainsFunc(t.Members, func(u User) bool { return u.ID == user.ID }) { + return nil + } + if user.TeamID.Valid && user.TeamID.UUID != t.ID { + return errors.New("user is already in another team") + } + if len(t.Members) >= MaxTeamMembers { + return errors.New("team is full") + } + t.Members = append(t.Members, user) + return nil +} diff --git a/server/domain/user.go b/server/domain/user.go index c66c710..322f622 100644 --- a/server/domain/user.go +++ b/server/domain/user.go @@ -1,11 +1,15 @@ package domain +import "github.com/google/uuid" + type User struct { - ID string + ID uuid.UUID Name string + + TeamID uuid.NullUUID } -func NewUser(id, name string) User { +func NewUser(id uuid.UUID, name string) User { return User{ ID: id, Name: name, diff --git a/server/handler/handler.go b/server/handler/handler.go index b6769cc..251dd68 100644 --- a/server/handler/handler.go +++ b/server/handler/handler.go @@ -7,12 +7,15 @@ import ( "github.com/traPtitech/piscon-portal-v2/server/handler/internal" "github.com/traPtitech/piscon-portal-v2/server/repository" "github.com/traPtitech/piscon-portal-v2/server/services/oauth2" + "github.com/traPtitech/piscon-portal-v2/server/usecase" ) type Handler struct { repo repository.Repository sessionManager SessionManager oauth2Service *oauth2.Service + + useCase usecase.UseCase } type Config struct { @@ -24,7 +27,7 @@ type Config struct { SessionManager SessionManager } -func New(repo repository.Repository, config Config) (*Handler, error) { +func New(useCase usecase.UseCase, repo repository.Repository, config Config) (*Handler, error) { var sessionManager SessionManager if config.SessionManager == nil { sessionManager = internal.NewSessionManager(config.SessionSecret, config.Debug) @@ -44,6 +47,7 @@ func New(repo repository.Repository, config Config) (*Handler, error) { repo: repo, sessionManager: sessionManager, oauth2Service: oauth2Service, + useCase: useCase, }, nil } @@ -54,4 +58,11 @@ func (h *Handler) SetupRoutes(e *echo.Echo) { api.GET("/oauth2/code", h.GetOauth2Code) api.GET("/oauth2/callback", h.Oauth2Callback) api.POST("/oauth2/logout", h.Logout, h.AuthMiddleware()) + + teams := api.Group("/teams", h.AuthMiddleware()) + teams.GET("", h.GetTeams) + teams.POST("", h.CreateTeam) + teams.GET("/:teamID", h.GetTeam) + // TODO: Admins can access even if they are not members of the team. + teams.PATCH("/:teamID", h.UpdateTeam, h.TeamAuthMiddleware()) } diff --git a/server/handler/handler_test.go b/server/handler/handler_test.go index 7b18940..abbed3b 100644 --- a/server/handler/handler_test.go +++ b/server/handler/handler_test.go @@ -23,6 +23,7 @@ import ( "github.com/traPtitech/piscon-portal-v2/server/handler" "github.com/traPtitech/piscon-portal-v2/server/repository" "github.com/traPtitech/piscon-portal-v2/server/services/oauth2" + "github.com/traPtitech/piscon-portal-v2/server/usecase" "github.com/traPtitech/piscon-portal-v2/server/utils/random" ) @@ -35,7 +36,7 @@ func TestMain(m *testing.M) { m.Run() } -func NewPortalServer(repo repository.Repository) *httptest.Server { +func NewPortalServer(useCase usecase.UseCase, repo repository.Repository) *httptest.Server { e := echo.New() server := httptest.NewTLSServer(e) @@ -50,7 +51,7 @@ func NewPortalServer(repo repository.Repository) *httptest.Server { TokenURL: oauth2ServerURL + "/token", }, } - h, err := handler.New(repo, config) + h, err := handler.New(useCase, repo, config) if err != nil { panic(err) } @@ -60,8 +61,7 @@ func NewPortalServer(repo repository.Repository) *httptest.Server { return server } -// NewHandler returns a new handler for middleware testing. -func NewHandler(repo repository.Repository, sessionManager handler.SessionManager) *handler.Handler { +func NewHandler(useCase usecase.UseCase, repo repository.Repository, sessionManager handler.SessionManager) *handler.Handler { config := handler.Config{ RootURL: "http://localhost", SessionSecret: "secret", @@ -74,7 +74,7 @@ func NewHandler(repo repository.Repository, sessionManager handler.SessionManage }, SessionManager: sessionManager, } - h, err := handler.New(repo, config) + h, err := handler.New(useCase, repo, config) if err != nil { panic(err) } @@ -91,7 +91,7 @@ func NewClient(server *httptest.Server) *http.Client { return client } -func Login(t *testing.T, server *httptest.Server, client *http.Client, userID string) error { +func Login(t *testing.T, server *httptest.Server, client *http.Client, userID uuid.UUID) error { t.Helper() // not following redirect for the first request @@ -117,7 +117,7 @@ func Login(t *testing.T, server *httptest.Server, client *http.Client, userID st return err } q := authURL.Query() - q.Add("user", userID) + q.Add("user", userID.String()) authURL.RawQuery = q.Encode() // from here, follow redirect diff --git a/server/handler/middleware.go b/server/handler/middleware.go index 80a74ec..59ec2a5 100644 --- a/server/handler/middleware.go +++ b/server/handler/middleware.go @@ -2,12 +2,22 @@ package handler import ( "errors" + "net/http" + "slices" "time" + "github.com/google/uuid" "github.com/labstack/echo/v4" + "github.com/traPtitech/piscon-portal-v2/server/domain" "github.com/traPtitech/piscon-portal-v2/server/repository" ) +const userIDKey = "userID" + +func getUserIDFromSession(c echo.Context) uuid.UUID { + return c.Get(userIDKey).(uuid.UUID) +} + func (h *Handler) AuthMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -36,6 +46,39 @@ func (h *Handler) AuthMiddleware() echo.MiddlewareFunc { return unauthorizedResponse(c, "session expired") } + c.Set(userIDKey, session.UserID) + + return next(c) + } + } +} + +// TeamAuthMiddleware is a middleware that checks if the user is a member of the team. +// The team ID is taken from the URL parameter. +func (h *Handler) TeamAuthMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + + userID := getUserIDFromSession(c) + teamID, err := uuid.Parse(c.Param("teamID")) + if err != nil { + return c.NoContent(http.StatusBadRequest) + } + + team, err := h.repo.FindTeam(ctx, teamID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return c.NoContent(http.StatusNotFound) + } + return internalServerErrorResponse(c, err) + } + + isMember := slices.ContainsFunc(team.Members, func(m domain.User) bool { return m.ID == userID }) + if !isMember { + return c.NoContent(http.StatusForbidden) + } + return next(c) } } diff --git a/server/handler/middleware_test.go b/server/handler/middleware_test.go index 0d73987..ec0c277 100644 --- a/server/handler/middleware_test.go +++ b/server/handler/middleware_test.go @@ -6,10 +6,12 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/traPtitech/piscon-portal-v2/server/domain" sessmock "github.com/traPtitech/piscon-portal-v2/server/handler/internal/mock" repomock "github.com/traPtitech/piscon-portal-v2/server/repository/mock" + usecasemock "github.com/traPtitech/piscon-portal-v2/server/usecase/mock" "go.uber.org/mock/gomock" ) @@ -18,9 +20,10 @@ func TestAuthMiddleware(t *testing.T) { ctrl := gomock.NewController(t) - mockRepo := repomock.NewMockRepository(ctrl) - mockSessManager := sessmock.NewMockSessionManager(ctrl) - handler := NewHandler(mockRepo, mockSessManager) + repoMock := repomock.NewMockRepository(ctrl) + sessMock := sessmock.NewMockSessionManager(ctrl) + usecaseMock := usecasemock.NewMockUseCase(ctrl) + handler := NewHandler(usecaseMock, repoMock, sessMock) needAuthorize := handler.AuthMiddleware()(func(c echo.Context) error { return c.NoContent(http.StatusOK) @@ -34,10 +37,10 @@ func TestAuthMiddleware(t *testing.T) { { name: "ok", setup: func() { - mockSessManager.EXPECT().GetSessionID(gomock.Any()).Return("sessionID", nil) - mockRepo.EXPECT().FindSession(gomock.Any(), "sessionID").Return(domain.Session{ + sessMock.EXPECT().GetSessionID(gomock.Any()).Return("sessionID", nil) + repoMock.EXPECT().FindSession(gomock.Any(), "sessionID").Return(domain.Session{ ID: "sessionID", - UserID: "test-user-id", + UserID: uuid.New(), ExpiresAt: time.Now().Add(time.Hour), }, nil) }, @@ -46,21 +49,21 @@ func TestAuthMiddleware(t *testing.T) { { name: "session not found", setup: func() { - mockSessManager.EXPECT().GetSessionID(gomock.Any()).Return("", nil) + sessMock.EXPECT().GetSessionID(gomock.Any()).Return("", nil) }, expectStatus: http.StatusUnauthorized, }, { name: "expired session", setup: func() { - mockSessManager.EXPECT().GetSessionID(gomock.Any()).Return("sessionID", nil) - mockRepo.EXPECT().FindSession(gomock.Any(), "sessionID").Return(domain.Session{ + sessMock.EXPECT().GetSessionID(gomock.Any()).Return("sessionID", nil) + repoMock.EXPECT().FindSession(gomock.Any(), "sessionID").Return(domain.Session{ ID: "sessionID", - UserID: "test-user-id", + UserID: uuid.New(), ExpiresAt: time.Now().Add(-time.Hour), }, nil) // expired session should be deleted - mockRepo.EXPECT().DeleteSession(gomock.Any(), "sessionID").Return(nil) + repoMock.EXPECT().DeleteSession(gomock.Any(), "sessionID").Return(nil) }, expectStatus: http.StatusUnauthorized, }, @@ -79,3 +82,69 @@ func TestAuthMiddleware(t *testing.T) { }) } } + +func TestTeamAuthMiddleware(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + + repoMock := repomock.NewMockRepository(ctrl) + sessMock := sessmock.NewMockSessionManager(ctrl) + usecaseMock := usecasemock.NewMockUseCase(ctrl) + handler := NewHandler(usecaseMock, repoMock, sessMock) + + userID := uuid.New() + teamID := uuid.New() + + needAuthorize := handler.TeamAuthMiddleware()(func(c echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + tests := []struct { + name string + setup func() + expectStatus int + }{ + { + name: "ok", + setup: func() { + repoMock.EXPECT().FindTeam(gomock.Any(), teamID).Return(domain.Team{ + ID: teamID, + Members: []domain.User{ + {ID: userID}, + }, + }, nil) + }, + expectStatus: http.StatusOK, + }, + { + name: "user is not a member of the team", + setup: func() { + repoMock.EXPECT().FindTeam(gomock.Any(), teamID).Return(domain.Team{ + ID: uuid.New(), + Members: []domain.User{{ID: uuid.New()}}, + }, nil) + }, + expectStatus: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + c := echo.New().NewContext(req, rec) + c.SetParamNames("teamID") + c.SetParamValues(teamID.String()) + c.Set("userID", userID) + + tt.setup() + + _ = needAuthorize(c) + if rec.Code != tt.expectStatus { + t.Errorf("unexpected status code: expected=%d, got=%d", tt.expectStatus, rec.Code) + } + }) + } +} diff --git a/server/handler/oauth2_test.go b/server/handler/oauth2_test.go index ad2dc4d..58cec99 100644 --- a/server/handler/oauth2_test.go +++ b/server/handler/oauth2_test.go @@ -11,18 +11,20 @@ import ( "github.com/google/uuid" "github.com/traPtitech/piscon-portal-v2/server/domain" "github.com/traPtitech/piscon-portal-v2/server/repository" - "github.com/traPtitech/piscon-portal-v2/server/repository/mock" + repomock "github.com/traPtitech/piscon-portal-v2/server/repository/mock" + usecasemock "github.com/traPtitech/piscon-portal-v2/server/usecase/mock" "go.uber.org/mock/gomock" ) func TestLogin(t *testing.T) { ctrl := gomock.NewController(t) - mockRepo := mock.NewMockRepository(ctrl) + mockRepo := repomock.NewMockRepository(ctrl) + usecaseMock := usecasemock.NewMockUseCase(ctrl) - server := NewPortalServer(mockRepo) + server := NewPortalServer(usecaseMock, mockRepo) client := NewClient(server) - userID := uuid.NewString() + userID := uuid.New() testFirstLogin(t, mockRepo, server, client, userID) } @@ -30,11 +32,12 @@ func TestLogin(t *testing.T) { func TestLoginAsExistingUser(t *testing.T) { ctrl := gomock.NewController(t) - mockRepo := mock.NewMockRepository(ctrl) + mockRepo := repomock.NewMockRepository(ctrl) + usecaseMock := usecasemock.NewMockUseCase(ctrl) - server := NewPortalServer(mockRepo) + server := NewPortalServer(usecaseMock, mockRepo) client := NewClient(server) - userID := uuid.NewString() + userID := uuid.New() // user already exists, so only create session mockRepo.EXPECT().Transaction(gomock.Any(), gomock.Any()). @@ -54,11 +57,12 @@ func TestLoginAsExistingUser(t *testing.T) { func TestLogout(t *testing.T) { ctrl := gomock.NewController(t) - mockRepo := mock.NewMockRepository(ctrl) + mockRepo := repomock.NewMockRepository(ctrl) + mockUseCase := usecasemock.NewMockUseCase(ctrl) - server := NewPortalServer(mockRepo) + server := NewPortalServer(mockUseCase, mockRepo) client := NewClient(server) - userID := uuid.NewString() + userID := uuid.New() testFirstLogin(t, mockRepo, server, client, userID) @@ -84,7 +88,7 @@ func TestLogout(t *testing.T) { } } -func testFirstLogin(t *testing.T, mockRepo *mock.MockRepository, server *httptest.Server, client *http.Client, userID string) { +func testFirstLogin(t *testing.T, mockRepo *repomock.MockRepository, server *httptest.Server, client *http.Client, userID uuid.UUID) { // create user and session mockRepo.EXPECT().Transaction(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, f func(context.Context, repository.Repository) error) error { diff --git a/server/handler/response.go b/server/handler/response.go index 4d38c4c..cbb4c27 100644 --- a/server/handler/response.go +++ b/server/handler/response.go @@ -14,8 +14,20 @@ func internalServerErrorResponse(c echo.Context, err error) error { }) } +func badRequestResponse(c echo.Context, msg string) error { + return c.JSON(http.StatusBadRequest, openapi.ErrorBadRequest{ + Message: openapi.NewOptString(msg), + }) +} + func unauthorizedResponse(c echo.Context, msg string) error { return c.JSON(http.StatusUnauthorized, openapi.Unauthorized{ Message: openapi.NewOptString(msg), }) } + +func notFoundResponse(c echo.Context) error { + return c.JSON(http.StatusNotFound, openapi.NotFound{ + Message: openapi.NewOptString("Not Found"), + }) +} diff --git a/server/handler/team.go b/server/handler/team.go new file mode 100644 index 0000000..3b7094b --- /dev/null +++ b/server/handler/team.go @@ -0,0 +1,117 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/samber/lo" + "github.com/traPtitech/piscon-portal-v2/server/domain" + "github.com/traPtitech/piscon-portal-v2/server/handler/openapi" + "github.com/traPtitech/piscon-portal-v2/server/usecase" +) + +func (h *Handler) GetTeams(c echo.Context) error { + ctx := c.Request().Context() + + teams, err := h.useCase.GetTeams(ctx) + if err != nil { + return internalServerErrorResponse(c, err) + } + + res := make(openapi.GetTeamsOKApplicationJSON, 0, len(teams)) + for _, team := range teams { + members := lo.Map(team.Members, func(u domain.User, _ int) openapi.UserId { + return openapi.UserId(u.ID) + }) + res = append(res, openapi.Team{ + ID: openapi.TeamId(team.ID), + Name: openapi.TeamName(team.Name), + Members: members, + CreatedAt: team.CreatedAt, + }) + } + + return c.JSON(http.StatusOK, res) +} + +func (h *Handler) CreateTeam(c echo.Context) error { + ctx := c.Request().Context() + + var req openapi.PostTeamReq + if err := c.Bind(&req); err != nil { + return badRequestResponse(c, err.Error()) + } + userID := getUserIDFromSession(c) + + team, err := h.useCase.CreateTeam(ctx, usecase.CreateTeamInput{ + Name: string(req.Name), + MemberIDs: lo.Map(req.Members, func(id openapi.UserId, _ int) uuid.UUID { return uuid.UUID(id) }), + CreatorID: userID, + }) + if err != nil { + if usecase.IsUseCaseError(err) { + return badRequestResponse(c, err.Error()) + } + return internalServerErrorResponse(c, err) + } + + return c.JSON(http.StatusCreated, toOpenAPITeam(team)) +} + +func (h *Handler) GetTeam(c echo.Context) error { + ctx := c.Request().Context() + + teamID, err := uuid.Parse(c.Param("teamID")) + if err != nil { + return badRequestResponse(c, err.Error()) + } + + team, err := h.useCase.GetTeam(ctx, teamID) + if err != nil { + if errors.Is(err, usecase.ErrNotFound) { + return notFoundResponse(c) + } + return internalServerErrorResponse(c, err) + } + + return c.JSON(http.StatusOK, toOpenAPITeam(team)) +} + +func (h *Handler) UpdateTeam(c echo.Context) error { + ctx := c.Request().Context() + + var req openapi.PatchTeamReq + if err := c.Bind(&req); err != nil { + return badRequestResponse(c, err.Error()) + } + teamID, err := uuid.Parse(c.Param("teamID")) + if err != nil { + return badRequestResponse(c, err.Error()) + } + + team, err := h.useCase.UpdateTeam(ctx, usecase.UpdateTeamInput{ + ID: teamID, + Name: string(req.Name.Value), + MemberIDs: lo.Map(req.Members, func(id openapi.UserId, _ int) uuid.UUID { return uuid.UUID(id) }), + }) + if err != nil { + if usecase.IsUseCaseError(err) { + return badRequestResponse(c, err.Error()) + } + return internalServerErrorResponse(c, err) + } + + return c.JSON(http.StatusOK, toOpenAPITeam(team)) +} + +func toOpenAPITeam(team domain.Team) openapi.Team { + return openapi.Team{ + ID: openapi.TeamId(team.ID), + Name: openapi.TeamName(team.Name), + Members: lo.Map(team.Members, func(m domain.User, _ int) openapi.UserId { return openapi.UserId(m.ID) }), + GithubIds: []openapi.GitHubId{}, // TODO: Implement + CreatedAt: team.CreatedAt, + } +} diff --git a/server/handler/team_test.go b/server/handler/team_test.go new file mode 100644 index 0000000..dcfbab9 --- /dev/null +++ b/server/handler/team_test.go @@ -0,0 +1,295 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traPtitech/piscon-portal-v2/server/domain" + "github.com/traPtitech/piscon-portal-v2/server/handler/openapi" + repomock "github.com/traPtitech/piscon-portal-v2/server/repository/mock" + "github.com/traPtitech/piscon-portal-v2/server/usecase" + usecasemock "github.com/traPtitech/piscon-portal-v2/server/usecase/mock" + "go.uber.org/mock/gomock" +) + +func TestGetTeams(t *testing.T) { + ctrl := gomock.NewController(t) + + repoMock := repomock.NewMockRepository(ctrl) + useCaseMock := usecasemock.NewMockUseCase(ctrl) + + e := echo.New() + h := NewHandler(useCaseMock, repoMock, nil) + + members := []domain.User{ + {ID: uuid.New()}, {ID: uuid.New()}, {ID: uuid.New()}, + } + teams := []domain.Team{ + { + ID: uuid.New(), + Name: "Team A", + Members: members, + }, + { + ID: uuid.New(), + Name: "Team B", + Members: members, + }, + } + + tests := []struct { + name string + teams []domain.Team + }{ + { + name: "success", + teams: teams, + }, + { + name: "empty", + teams: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/teams", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + useCaseMock.EXPECT().GetTeams(gomock.Any()).Return(tt.teams, nil) + + _ = h.GetTeams(c) + + if !assert.Equal(t, http.StatusOK, rec.Code) { + t.Log(rec.Body.String()) + } + var res openapi.GetTeamsOKApplicationJSON + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) + assert.Len(t, res, len(tt.teams)) + for i, team := range tt.teams { + compareTeam(t, team, res[i]) + } + }) + } +} + +func TestCreateTeam(t *testing.T) { + ctrl := gomock.NewController(t) + + repoMock := repomock.NewMockRepository(ctrl) + useCaseMock := usecasemock.NewMockUseCase(ctrl) + + userID := uuid.New() + + e := echo.New() + req := &openapi.PostTeamReq{ + Name: "Team A", + Members: []openapi.UserId{openapi.UserId(userID)}, + } + httpReq := newJSONRequest(http.MethodPost, "/teams", req) + rec := httptest.NewRecorder() + c := e.NewContext(httpReq, rec) + c.Set("userID", userID) + h := NewHandler(useCaseMock, repoMock, nil) + + teamID := uuid.New() + useCaseMock.EXPECT().CreateTeam(gomock.Any(), usecase.CreateTeamInput{ + Name: "Team A", + MemberIDs: []uuid.UUID{userID}, + CreatorID: userID, + }).Return(domain.Team{ + ID: teamID, + Name: "Team A", + Members: []domain.User{{ID: userID}}, + }, nil) + + _ = h.CreateTeam(c) + + if !assert.Equal(t, http.StatusCreated, rec.Code) { + t.Log(rec.Body.String()) + } + var res openapi.Team + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) + assert.Equal(t, teamID, uuid.UUID(res.ID)) +} + +func TestCreateTeam_Error(t *testing.T) { + ctrl := gomock.NewController(t) + + repoMock := repomock.NewMockRepository(ctrl) + useCaseMock := usecasemock.NewMockUseCase(ctrl) + + userID := uuid.New() + + e := echo.New() + req := &openapi.PostTeamReq{ + Name: "Team A", + Members: []openapi.UserId{openapi.UserId(userID)}, + } + httpReq := newJSONRequest(http.MethodPost, "/teams", req) + rec := httptest.NewRecorder() + c := e.NewContext(httpReq, rec) + c.Set("userID", userID) + h := NewHandler(useCaseMock, repoMock, nil) + + useCaseMock.EXPECT().CreateTeam(gomock.Any(), usecase.CreateTeamInput{ + Name: "Team A", + MemberIDs: []uuid.UUID{userID}, + CreatorID: userID, + }).Return(domain.Team{}, usecase.NewUseCaseErrorFromMsg("user is already in another team")) + + _ = h.CreateTeam(c) + + if !assert.Equal(t, http.StatusBadRequest, rec.Code) { + t.Log(rec.Body.String()) + } +} + +func TestGetTeam(t *testing.T) { + ctrl := gomock.NewController(t) + + repoMock := repomock.NewMockRepository(ctrl) + useCaseMock := usecasemock.NewMockUseCase(ctrl) + + e := echo.New() + teamID := uuid.New() + req := httptest.NewRequest(http.MethodGet, "/teams/"+teamID.String(), nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("teamID") + c.SetParamValues(teamID.String()) + h := NewHandler(useCaseMock, repoMock, nil) + + team := domain.Team{ + ID: teamID, + Name: "Team A", + Members: []domain.User{ + {ID: uuid.New()}, + }, + } + + useCaseMock.EXPECT().GetTeam(gomock.Any(), teamID).Return(team, nil) + + _ = h.GetTeam(c) + + if !assert.Equal(t, http.StatusOK, rec.Code) { + t.Log(rec.Body.String()) + } + var res openapi.Team + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) + compareTeam(t, team, res) +} + +func TestGetTeam_NotFound(t *testing.T) { + ctrl := gomock.NewController(t) + + repoMock := repomock.NewMockRepository(ctrl) + useCaseMock := usecasemock.NewMockUseCase(ctrl) + + e := echo.New() + teamID := uuid.New() + req := httptest.NewRequest(http.MethodGet, "/teams/"+teamID.String(), nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("teamID") + c.SetParamValues(teamID.String()) + h := NewHandler(useCaseMock, repoMock, nil) + + useCaseMock.EXPECT().GetTeam(gomock.Any(), teamID).Return(domain.Team{}, usecase.ErrNotFound) + + _ = h.GetTeam(c) + + if !assert.Equal(t, http.StatusNotFound, rec.Code) { + t.Log(rec.Body.String()) + } +} + +func TestUpdateTeam(t *testing.T) { + ctrl := gomock.NewController(t) + + repoMock := repomock.NewMockRepository(ctrl) + useCaseMock := usecasemock.NewMockUseCase(ctrl) + + e := echo.New() + teamID := uuid.New() + newMemberID := uuid.New() + req := &openapi.PatchTeamReq{ + Name: openapi.NewOptTeamName("Updated Team"), + Members: []openapi.UserId{openapi.UserId(newMemberID)}, + } + httpReq := newJSONRequest(http.MethodPatch, "/teams/"+teamID.String(), req) + rec := httptest.NewRecorder() + c := e.NewContext(httpReq, rec) + c.SetParamNames("teamID") + c.SetParamValues(teamID.String()) + h := NewHandler(useCaseMock, repoMock, nil) + + useCaseMock.EXPECT().UpdateTeam(gomock.Any(), usecase.UpdateTeamInput{ + ID: teamID, + Name: "Updated Team", + MemberIDs: []uuid.UUID{newMemberID}, + }).Return(domain.Team{ + ID: teamID, + Name: "Updated Team", + Members: []domain.User{{ID: newMemberID}}, + }, nil) + + _ = h.UpdateTeam(c) + + if !assert.Equal(t, http.StatusOK, rec.Code) { + t.Log(rec.Body.String()) + } + var res openapi.Team + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) + assert.Equal(t, teamID, uuid.UUID(res.ID)) +} + +func TestUpdateTeam_Error(t *testing.T) { + ctrl := gomock.NewController(t) + + repoMock := repomock.NewMockRepository(ctrl) + useCaseMock := usecasemock.NewMockUseCase(ctrl) + + e := echo.New() + teamID := uuid.New() + newMemberID := uuid.New() + req := &openapi.PatchTeamReq{ + Name: openapi.NewOptTeamName("Updated Team"), + Members: []openapi.UserId{openapi.UserId(newMemberID)}, + } + httpReq := newJSONRequest(http.MethodPatch, "/teams/"+teamID.String(), req) + rec := httptest.NewRecorder() + c := e.NewContext(httpReq, rec) + c.SetParamNames("teamID") + c.SetParamValues(teamID.String()) + h := NewHandler(useCaseMock, repoMock, nil) + + useCaseMock.EXPECT().UpdateTeam(gomock.Any(), usecase.UpdateTeamInput{ + ID: teamID, + Name: "Updated Team", + MemberIDs: []uuid.UUID{newMemberID}, + }).Return(domain.Team{}, usecase.NewUseCaseErrorFromMsg("team is full")) + + _ = h.UpdateTeam(c) + + if !assert.Equal(t, http.StatusBadRequest, rec.Code) { + t.Log(rec.Body.String()) + } +} + +func compareTeam(t *testing.T, expected domain.Team, actual openapi.Team) { + t.Helper() + assert.Equal(t, expected.ID, uuid.UUID(actual.ID)) + assert.Equal(t, expected.Name, string(actual.Name)) + assert.Len(t, actual.Members, len(expected.Members)) + for i, member := range expected.Members { + assert.Equal(t, member.ID, uuid.UUID(actual.Members[i])) + } +} diff --git a/server/handler/util_test.go b/server/handler/util_test.go new file mode 100644 index 0000000..a559bef --- /dev/null +++ b/server/handler/util_test.go @@ -0,0 +1,17 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/labstack/echo/v4" +) + +func newJSONRequest(method string, path string, req json.Marshaler) *http.Request { + body, _ := req.MarshalJSON() + httpReq := httptest.NewRequest(method, path, bytes.NewReader(body)) + httpReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + return httpReq +} diff --git a/server/repository/db/db_test.go b/server/repository/db/db_test.go new file mode 100644 index 0000000..fb424de --- /dev/null +++ b/server/repository/db/db_test.go @@ -0,0 +1,110 @@ +package db_test + +import ( + "context" + "database/sql" + _ "embed" + "math/rand/v2" + "os" + "testing" + "time" + + "github.com/stephenafamo/bob" + "github.com/testcontainers/testcontainers-go/modules/mysql" + dbrepo "github.com/traPtitech/piscon-portal-v2/server/repository/db" +) + +var mysqlContainer *mysql.MySQLContainer + +func TestMain(m *testing.M) { + ctx := context.Background() + + container, err := mysql.Run(ctx, + "mysql:8", + mysql.WithUsername("root"), + mysql.WithPassword("password"), + ) + if err != nil { + panic(err) + } + defer container.Terminate(ctx) //nolint errcheck + + connection := container.MustConnectionString(ctx) + db, err := sql.Open("mysql", connection) + if err != nil { + panic(err) + } + retry(30, func() error { return db.Ping() }) // ensure db is ready + db.Close() + + mysqlContainer = container + + m.Run() +} + +func setupRepository(t *testing.T) (*dbrepo.Repository, bob.Executor) { + t.Helper() + + ctx := context.Background() + + dbName := randomDBName() + if err := createDatabase(dbName); err != nil { + t.Fatal(err) + } + connection := mysqlContainer.MustConnectionString(ctx, "parseTime=true") + db, err := sql.Open("mysql", connection) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("USE " + dbName); err != nil { + t.Fatal(err) + } + + return dbrepo.NewRepository(db), bob.New(db) +} + +func createDatabase(name string) error { + connection := mysqlContainer.MustConnectionString(context.Background(), "multiStatements=true") + db, err := sql.Open("mysql", connection) + if err != nil { + return err + } + defer db.Close() + + _, err = db.Exec("CREATE DATABASE " + name) + if err != nil { + return err + } + schema, err := os.ReadFile("testdata/schema.sql") + if err != nil { + return err + } + + if _, err := db.Exec("USE " + name); err != nil { + return err + } + _, err = db.Exec(string(schema)) + return err +} + +// retry retries f until it returns nil or n retries are reached. panic if n retries are reached. +func retry(n int, f func() error) { + var err error + for i := 0; i < n; i++ { + err = f() + if err == nil { + return + } + time.Sleep(1 * time.Second) + } + panic(err) +} + +func randomDBName() string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + var buf [10]byte + for i := range buf { + buf[i] = charset[rand.IntN(len(charset))] + } + return "test_" + string(buf[:]) +} diff --git a/server/repository/db/session.go b/server/repository/db/session.go index 658a60a..e4ec32f 100644 --- a/server/repository/db/session.go +++ b/server/repository/db/session.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/aarondl/opt/omit" + "github.com/google/uuid" "github.com/stephenafamo/bob" "github.com/traPtitech/piscon-portal-v2/server/domain" "github.com/traPtitech/piscon-portal-v2/server/repository" @@ -34,13 +35,13 @@ func findSession(ctx context.Context, executor bob.Executor, id string) (domain. return domain.Session{}, fmt.Errorf("find session: %w", err) } - return toDomainSession(session), nil + return toDomainSession(session) } func createSession(ctx context.Context, executor bob.Executor, session domain.Session) error { _, err := models.Sessions.Insert(&models.SessionSetter{ ID: omit.From(session.ID), - UserID: omit.From(session.UserID), + UserID: omit.From(session.UserID.String()), ExpiredAt: omit.From(session.ExpiresAt), }).Exec(ctx, executor) if err != nil { @@ -57,10 +58,14 @@ func deleteSession(ctx context.Context, executor bob.Executor, id string) error return nil } -func toDomainSession(session *models.Session) domain.Session { +func toDomainSession(session *models.Session) (domain.Session, error) { + userID, err := uuid.Parse(session.UserID) + if err != nil { + return domain.Session{}, err + } return domain.Session{ ID: session.ID, - UserID: session.UserID, + UserID: userID, ExpiresAt: session.ExpiredAt, - } + }, nil } diff --git a/server/repository/db/team.go b/server/repository/db/team.go new file mode 100644 index 0000000..4416be9 --- /dev/null +++ b/server/repository/db/team.go @@ -0,0 +1,119 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" + "github.com/google/uuid" + "github.com/samber/lo" + "github.com/traPtitech/piscon-portal-v2/server/domain" + "github.com/traPtitech/piscon-portal-v2/server/repository" + "github.com/traPtitech/piscon-portal-v2/server/repository/db/models" +) + +func (r *Repository) FindTeam(ctx context.Context, id uuid.UUID) (domain.Team, error) { + team, err := models.Teams.Query(models.SelectWhere.Teams.ID.EQ(id.String()), models.ThenLoadTeamUsers()).One(ctx, r.executor(ctx)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.Team{}, repository.ErrNotFound + } + return domain.Team{}, fmt.Errorf("find team: %w", err) + } + return toDomainTeam(team) +} + +func (r *Repository) GetTeams(ctx context.Context) ([]domain.Team, error) { + teams, err := models.Teams.Query(models.ThenLoadTeamUsers()).All(ctx, r.executor(ctx)) + if err != nil { + return nil, fmt.Errorf("get teams: %w", err) + } + res := make([]domain.Team, 0, len(teams)) + for _, team := range teams { + domainTeam, err := toDomainTeam(team) + if err != nil { + return nil, fmt.Errorf("to domain team: %w", err) + } + res = append(res, domainTeam) + } + return res, nil +} + +func (r *Repository) CreateTeam(ctx context.Context, team domain.Team) error { + if ctx.Value(executorCtxKey) == nil { + return r.Transaction(ctx, func(ctx context.Context, r repository.Repository) error { + return r.CreateTeam(ctx, team) + }) + } + + _, err := models.Teams.Insert(&models.TeamSetter{ + ID: omit.From(team.ID.String()), + Name: omit.From(team.Name), + CreatedAt: omit.From(team.CreatedAt), + }).Exec(ctx, r.executor(ctx)) + if err != nil { + return fmt.Errorf("create team: %w", err) + } + + memberIDs := lo.Map(team.Members, func(m domain.User, _ int) string { return m.ID.String() }) + _, err = models.Users.Update( + models.UpdateWhere.Users.ID.In(memberIDs...), + models.UserSetter{ + TeamID: omitnull.From(team.ID.String()), + }.UpdateMod(), + ).Exec(ctx, r.executor(ctx)) + + return err +} + +func (r *Repository) UpdateTeam(ctx context.Context, team domain.Team) error { + if ctx.Value(executorCtxKey) == nil { + return r.Transaction(ctx, func(ctx context.Context, r repository.Repository) error { + return r.UpdateTeam(ctx, team) + }) + } + + _, err := models.Teams.Update( + models.UpdateWhere.Teams.ID.EQ(team.ID.String()), + models.TeamSetter{ + Name: omit.From(team.Name), + }.UpdateMod(), + ).Exec(ctx, r.executor(ctx)) + if err != nil { + return fmt.Errorf("update team: %w", err) + } + + memberIDs := lo.Map(team.Members, func(m domain.User, _ int) string { return m.ID.String() }) + _, err = models.Users.Update( + models.UpdateWhere.Users.ID.In(memberIDs...), + models.UserSetter{ + TeamID: omitnull.From(team.ID.String()), + }.UpdateMod(), + ).Exec(ctx, r.executor(ctx)) + + return err +} + +func toDomainTeam(team *models.Team) (domain.Team, error) { + members := make([]domain.User, 0, len(team.R.Users)) + for _, user := range team.R.Users { + domainUser, err := toDomainUser(user) + if err != nil { + return domain.Team{}, fmt.Errorf("to domain user: %w", err) + } + members = append(members, domainUser) + } + teamID, err := uuid.Parse(team.ID) + if err != nil { + return domain.Team{}, fmt.Errorf("parse team ID: %w", err) + } + return domain.Team{ + ID: teamID, + Name: team.Name, + Members: members, + CreatedAt: team.CreatedAt, + }, nil +} diff --git a/server/repository/db/team_test.go b/server/repository/db/team_test.go new file mode 100644 index 0000000..962ad18 --- /dev/null +++ b/server/repository/db/team_test.go @@ -0,0 +1,196 @@ +package db_test + +import ( + "cmp" + "context" + "slices" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traPtitech/piscon-portal-v2/server/domain" +) + +func TestGetTeam(t *testing.T) { + t.Parallel() + + repo, db := setupRepository(t) + + teamID := uuid.New() + members := []domain.User{ + { + ID: uuid.New(), + Name: "user1", + TeamID: uuid.NullUUID{UUID: teamID, Valid: true}, + }, + { + ID: uuid.New(), + Name: "user2", + TeamID: uuid.NullUUID{UUID: teamID, Valid: true}, + }, + } + team := domain.Team{ + ID: teamID, + Name: "team1", + Members: members, + CreatedAt: time.Now(), + } + mustMakeTeam(t, db, team) + for _, member := range members { + mustMakeUser(t, db, member) + } + + got, err := repo.FindTeam(context.Background(), team.ID) + require.NoError(t, err) + + compareTeam(t, team, got) +} + +func TestGetTeams(t *testing.T) { + t.Parallel() + + repo, db := setupRepository(t) + + teamID1 := uuid.New() + teamID2 := uuid.New() + + members1 := []domain.User{ + { + ID: uuid.New(), + Name: "user1", + TeamID: uuid.NullUUID{UUID: teamID1, Valid: true}, + }, + { + ID: uuid.New(), + Name: "user2", + TeamID: uuid.NullUUID{UUID: teamID1, Valid: true}, + }, + } + members2 := []domain.User{ + { + ID: uuid.New(), + Name: "user3", + TeamID: uuid.NullUUID{UUID: teamID2, Valid: true}, + }, + { + ID: uuid.New(), + Name: "user4", + TeamID: uuid.NullUUID{UUID: teamID2, Valid: true}, + }, + } + teams := []domain.Team{ + { + ID: teamID1, + Name: "team1", + Members: members1, + CreatedAt: time.Now(), + }, + { + ID: teamID2, + Name: "team2", + Members: members2, + CreatedAt: time.Now(), + }, + } + for _, team := range teams { + mustMakeTeam(t, db, team) + for _, member := range team.Members { + mustMakeUser(t, db, member) + } + } + + got, err := repo.GetTeams(context.Background()) + require.NoError(t, err) + + assert.Len(t, got, len(teams)) + + // sort teams by ID + slices.SortFunc(got, func(a, b domain.Team) int { return cmp.Compare(a.ID.String(), b.ID.String()) }) + slices.SortFunc(teams, func(a, b domain.Team) int { return cmp.Compare(a.ID.String(), b.ID.String()) }) + for i, team := range teams { + compareTeam(t, team, got[i]) + } +} + +func TestCreateTeam(t *testing.T) { + t.Parallel() + + repo, db := setupRepository(t) + + members := []domain.User{ + { + ID: uuid.New(), + Name: "user1", + }, + { + ID: uuid.New(), + Name: "user2", + }, + } + team := domain.Team{ + ID: uuid.New(), + Name: "team1", + Members: members, + CreatedAt: time.Now(), + } + for _, member := range members { + mustMakeUser(t, db, member) + } + + err := repo.CreateTeam(context.Background(), team) + if !assert.NoError(t, err) { + return + } + + got, err := repo.FindTeam(context.Background(), team.ID) + require.NoError(t, err) + + compareTeam(t, team, got) +} + +func TestUpdateTeam(t *testing.T) { + t.Parallel() + + repo, db := setupRepository(t) + + team := domain.Team{ + ID: uuid.New(), + Name: "team1", + Members: nil, + CreatedAt: time.Now(), + } + newMember := domain.User{ + ID: uuid.New(), + Name: "user2", + } + mustMakeUser(t, db, newMember) + mustMakeTeam(t, db, team) + + // change the team name and add a new member + team.Name = "team2" + require.NoError(t, team.AddMember(newMember)) + err := repo.UpdateTeam(context.Background(), team) + assert.NoError(t, err) + + got, err := repo.FindTeam(context.Background(), team.ID) + require.NoError(t, err) + + compareTeam(t, team, got) +} + +func compareTeam(t *testing.T, want domain.Team, got domain.Team) { + t.Helper() + + assert.Equal(t, want.ID, got.ID) + assert.Equal(t, want.Name, got.Name) + assert.Len(t, want.Members, len(got.Members)) + // sort members by ID + slices.SortFunc(got.Members, func(a, b domain.User) int { return cmp.Compare(a.ID.String(), b.ID.String()) }) + slices.SortFunc(want.Members, func(a, b domain.User) int { return cmp.Compare(a.ID.String(), b.ID.String()) }) + for i, member := range want.Members { + assert.Equal(t, member.ID, got.Members[i].ID) + assert.Equal(t, member.Name, got.Members[i].Name) + } +} diff --git a/server/repository/db/testdata/schema.sql b/server/repository/db/testdata/schema.sql new file mode 120000 index 0000000..17f1848 --- /dev/null +++ b/server/repository/db/testdata/schema.sql @@ -0,0 +1 @@ +../../../schema.sql \ No newline at end of file diff --git a/server/repository/db/user.go b/server/repository/db/user.go index ebc98a9..34e9103 100644 --- a/server/repository/db/user.go +++ b/server/repository/db/user.go @@ -7,14 +7,15 @@ import ( "fmt" "github.com/aarondl/opt/omit" + "github.com/google/uuid" "github.com/stephenafamo/bob" "github.com/traPtitech/piscon-portal-v2/server/domain" "github.com/traPtitech/piscon-portal-v2/server/repository" "github.com/traPtitech/piscon-portal-v2/server/repository/db/models" ) -func (r *Repository) FindUser(ctx context.Context, id string) (domain.User, error) { - return findUser(ctx, r.executor(ctx), id) +func (r *Repository) FindUser(ctx context.Context, id uuid.UUID) (domain.User, error) { + return findUser(ctx, r.executor(ctx), id.String()) } func (r *Repository) CreateUser(ctx context.Context, user domain.User) error { @@ -30,12 +31,12 @@ func findUser(ctx context.Context, executor bob.Executor, id string) (domain.Use return domain.User{}, fmt.Errorf("find user: %w", err) } - return toDomainUser(user), nil + return toDomainUser(user) } func createUser(ctx context.Context, executor bob.Executor, user domain.User) error { _, err := models.Users.Insert(&models.UserSetter{ - ID: omit.From(user.ID), + ID: omit.From(user.ID.String()), Name: omit.From(user.Name), }).Exec(ctx, executor) if err != nil { @@ -44,9 +45,27 @@ func createUser(ctx context.Context, executor bob.Executor, user domain.User) er return nil } -func toDomainUser(user *models.User) domain.User { - return domain.User{ - ID: user.ID, - Name: user.Name, +func toDomainUser(user *models.User) (domain.User, error) { + userID, err := uuid.Parse(user.ID) + if err != nil { + return domain.User{}, fmt.Errorf("parse user ID: %w", err) + } + + var teamID uuid.NullUUID + if id, ok := user.TeamID.Get(); ok { + parsedID, err := uuid.Parse(id) + if err != nil { + return domain.User{}, fmt.Errorf("parse team ID: %w", err) + } + teamID = uuid.NullUUID{ + UUID: parsedID, + Valid: true, + } } + + return domain.User{ + ID: userID, + Name: user.Name, + TeamID: teamID, + }, nil } diff --git a/server/repository/db/util_test.go b/server/repository/db/util_test.go new file mode 100644 index 0000000..3329358 --- /dev/null +++ b/server/repository/db/util_test.go @@ -0,0 +1,34 @@ +package db_test + +import ( + "context" + "testing" + + "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" + "github.com/samber/lo" + "github.com/stephenafamo/bob" + "github.com/stretchr/testify/require" + "github.com/traPtitech/piscon-portal-v2/server/domain" + "github.com/traPtitech/piscon-portal-v2/server/repository/db/models" +) + +func mustMakeUser(t *testing.T, executor bob.Executor, user domain.User) { + t.Helper() + _, err := models.Users.Insert(&models.UserSetter{ + ID: omit.From(user.ID.String()), + Name: omit.From(user.Name), + TeamID: lo.Ternary(user.TeamID.Valid, omitnull.From(user.TeamID.UUID.String()), omitnull.Val[string]{}), + }).Exec(context.Background(), executor) + require.NoError(t, err) +} + +func mustMakeTeam(t *testing.T, executor bob.Executor, team domain.Team) { + t.Helper() + _, err := models.Teams.Insert(&models.TeamSetter{ + ID: omit.From(team.ID.String()), + Name: omit.From(team.Name), + CreatedAt: omit.From(team.CreatedAt), + }).Exec(context.Background(), executor) + require.NoError(t, err) +} diff --git a/server/repository/mock/repository.go b/server/repository/mock/repository.go index 48b5503..e10ba67 100644 --- a/server/repository/mock/repository.go +++ b/server/repository/mock/repository.go @@ -13,6 +13,7 @@ import ( context "context" reflect "reflect" + uuid "github.com/google/uuid" domain "github.com/traPtitech/piscon-portal-v2/server/domain" repository "github.com/traPtitech/piscon-portal-v2/server/repository" gomock "go.uber.org/mock/gomock" @@ -80,6 +81,44 @@ func (c *MockRepositoryCreateSessionCall) DoAndReturn(f func(context.Context, do return c } +// CreateTeam mocks base method. +func (m *MockRepository) CreateTeam(ctx context.Context, team domain.Team) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTeam", ctx, team) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateTeam indicates an expected call of CreateTeam. +func (mr *MockRepositoryMockRecorder) CreateTeam(ctx, team any) *MockRepositoryCreateTeamCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeam", reflect.TypeOf((*MockRepository)(nil).CreateTeam), ctx, team) + return &MockRepositoryCreateTeamCall{Call: call} +} + +// MockRepositoryCreateTeamCall wrap *gomock.Call +type MockRepositoryCreateTeamCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRepositoryCreateTeamCall) Return(arg0 error) *MockRepositoryCreateTeamCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRepositoryCreateTeamCall) Do(f func(context.Context, domain.Team) error) *MockRepositoryCreateTeamCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRepositoryCreateTeamCall) DoAndReturn(f func(context.Context, domain.Team) error) *MockRepositoryCreateTeamCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // CreateUser mocks base method. func (m *MockRepository) CreateUser(ctx context.Context, user domain.User) error { m.ctrl.T.Helper() @@ -195,8 +234,47 @@ func (c *MockRepositoryFindSessionCall) DoAndReturn(f func(context.Context, stri return c } +// FindTeam mocks base method. +func (m *MockRepository) FindTeam(ctx context.Context, id uuid.UUID) (domain.Team, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindTeam", ctx, id) + ret0, _ := ret[0].(domain.Team) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindTeam indicates an expected call of FindTeam. +func (mr *MockRepositoryMockRecorder) FindTeam(ctx, id any) *MockRepositoryFindTeamCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindTeam", reflect.TypeOf((*MockRepository)(nil).FindTeam), ctx, id) + return &MockRepositoryFindTeamCall{Call: call} +} + +// MockRepositoryFindTeamCall wrap *gomock.Call +type MockRepositoryFindTeamCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRepositoryFindTeamCall) Return(arg0 domain.Team, arg1 error) *MockRepositoryFindTeamCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRepositoryFindTeamCall) Do(f func(context.Context, uuid.UUID) (domain.Team, error)) *MockRepositoryFindTeamCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRepositoryFindTeamCall) DoAndReturn(f func(context.Context, uuid.UUID) (domain.Team, error)) *MockRepositoryFindTeamCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // FindUser mocks base method. -func (m *MockRepository) FindUser(ctx context.Context, id string) (domain.User, error) { +func (m *MockRepository) FindUser(ctx context.Context, id uuid.UUID) (domain.User, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "FindUser", ctx, id) ret0, _ := ret[0].(domain.User) @@ -223,13 +301,52 @@ func (c *MockRepositoryFindUserCall) Return(arg0 domain.User, arg1 error) *MockR } // Do rewrite *gomock.Call.Do -func (c *MockRepositoryFindUserCall) Do(f func(context.Context, string) (domain.User, error)) *MockRepositoryFindUserCall { +func (c *MockRepositoryFindUserCall) Do(f func(context.Context, uuid.UUID) (domain.User, error)) *MockRepositoryFindUserCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRepositoryFindUserCall) DoAndReturn(f func(context.Context, string) (domain.User, error)) *MockRepositoryFindUserCall { +func (c *MockRepositoryFindUserCall) DoAndReturn(f func(context.Context, uuid.UUID) (domain.User, error)) *MockRepositoryFindUserCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// GetTeams mocks base method. +func (m *MockRepository) GetTeams(ctx context.Context) ([]domain.Team, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTeams", ctx) + ret0, _ := ret[0].([]domain.Team) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTeams indicates an expected call of GetTeams. +func (mr *MockRepositoryMockRecorder) GetTeams(ctx any) *MockRepositoryGetTeamsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeams", reflect.TypeOf((*MockRepository)(nil).GetTeams), ctx) + return &MockRepositoryGetTeamsCall{Call: call} +} + +// MockRepositoryGetTeamsCall wrap *gomock.Call +type MockRepositoryGetTeamsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRepositoryGetTeamsCall) Return(arg0 []domain.Team, arg1 error) *MockRepositoryGetTeamsCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRepositoryGetTeamsCall) Do(f func(context.Context) ([]domain.Team, error)) *MockRepositoryGetTeamsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRepositoryGetTeamsCall) DoAndReturn(f func(context.Context) ([]domain.Team, error)) *MockRepositoryGetTeamsCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -271,3 +388,41 @@ func (c *MockRepositoryTransactionCall) DoAndReturn(f func(context.Context, func c.Call = c.Call.DoAndReturn(f) return c } + +// UpdateTeam mocks base method. +func (m *MockRepository) UpdateTeam(ctx context.Context, team domain.Team) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTeam", ctx, team) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTeam indicates an expected call of UpdateTeam. +func (mr *MockRepositoryMockRecorder) UpdateTeam(ctx, team any) *MockRepositoryUpdateTeamCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTeam", reflect.TypeOf((*MockRepository)(nil).UpdateTeam), ctx, team) + return &MockRepositoryUpdateTeamCall{Call: call} +} + +// MockRepositoryUpdateTeamCall wrap *gomock.Call +type MockRepositoryUpdateTeamCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRepositoryUpdateTeamCall) Return(arg0 error) *MockRepositoryUpdateTeamCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRepositoryUpdateTeamCall) Do(f func(context.Context, domain.Team) error) *MockRepositoryUpdateTeamCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRepositoryUpdateTeamCall) DoAndReturn(f func(context.Context, domain.Team) error) *MockRepositoryUpdateTeamCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/server/repository/repository.go b/server/repository/repository.go index d61b57a..4ab8840 100644 --- a/server/repository/repository.go +++ b/server/repository/repository.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/google/uuid" "github.com/traPtitech/piscon-portal-v2/server/domain" ) @@ -14,7 +15,7 @@ type Repository interface { Transaction(ctx context.Context, f func(ctx context.Context, r Repository) error) error // FindUser finds a user by id. If the user is not found, it returns [ErrNotFound]. - FindUser(ctx context.Context, id string) (domain.User, error) + FindUser(ctx context.Context, id uuid.UUID) (domain.User, error) // CreateUser creates a user. CreateUser(ctx context.Context, user domain.User) error @@ -24,6 +25,15 @@ type Repository interface { CreateSession(ctx context.Context, session domain.Session) error // DeleteSession deletes a session by id. DeleteSession(ctx context.Context, id string) error + + // FindTeam finds a team by id. If the team is not found, it returns [ErrNotFound]. + FindTeam(ctx context.Context, id uuid.UUID) (domain.Team, error) + // GetTeams returns all teams. + GetTeams(ctx context.Context) ([]domain.Team, error) + // CreateTeam creates a team. + CreateTeam(ctx context.Context, team domain.Team) error + // UpdateTeam updates a team. + UpdateTeam(ctx context.Context, team domain.Team) error } var ( diff --git a/server/services/oauth2/oauth2.go b/server/services/oauth2/oauth2.go index 704e2b2..86b551a 100644 --- a/server/services/oauth2/oauth2.go +++ b/server/services/oauth2/oauth2.go @@ -6,6 +6,7 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" "github.com/jellydator/ttlcache/v3" "golang.org/x/oauth2" ) @@ -88,8 +89,8 @@ func (s *Service) Exchange(ctx context.Context, sessionID, code string) (*oauth2 } type TraQUserInfo struct { - ID string `json:"sub"` - Name string `json:"name"` + ID uuid.UUID `json:"sub"` + Name string `json:"name"` } func (s *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*TraQUserInfo, error) { diff --git a/server/usecase/error.go b/server/usecase/error.go new file mode 100644 index 0000000..72bf097 --- /dev/null +++ b/server/usecase/error.go @@ -0,0 +1,32 @@ +package usecase + +import "errors" + +type UseCaseError struct { //nolint revive + err error +} + +func (e UseCaseError) Error() string { + return e.err.Error() +} + +func (e UseCaseError) Unwrap() error { + return e.err +} + +func NewUseCaseErrorFromMsg(msg string) UseCaseError { + return UseCaseError{err: errors.New(msg)} +} + +func NewUseCaseError(err error) UseCaseError { + return UseCaseError{err: err} +} + +func IsUseCaseError(err error) bool { + if err == nil { + return false + } + return errors.As(err, &UseCaseError{}) +} + +var ErrNotFound = errors.New("not found") diff --git a/server/usecase/mock/usecase.go b/server/usecase/mock/usecase.go new file mode 100644 index 0000000..a139421 --- /dev/null +++ b/server/usecase/mock/usecase.go @@ -0,0 +1,200 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: usecase.go +// +// Generated by this command: +// +// mockgen -source=usecase.go -destination=mock/usecase.go -package=mock -typed=true +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + uuid "github.com/google/uuid" + domain "github.com/traPtitech/piscon-portal-v2/server/domain" + usecase "github.com/traPtitech/piscon-portal-v2/server/usecase" + gomock "go.uber.org/mock/gomock" +) + +// MockUseCase is a mock of UseCase interface. +type MockUseCase struct { + ctrl *gomock.Controller + recorder *MockUseCaseMockRecorder + isgomock struct{} +} + +// MockUseCaseMockRecorder is the mock recorder for MockUseCase. +type MockUseCaseMockRecorder struct { + mock *MockUseCase +} + +// NewMockUseCase creates a new mock instance. +func NewMockUseCase(ctrl *gomock.Controller) *MockUseCase { + mock := &MockUseCase{ctrl: ctrl} + mock.recorder = &MockUseCaseMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUseCase) EXPECT() *MockUseCaseMockRecorder { + return m.recorder +} + +// CreateTeam mocks base method. +func (m *MockUseCase) CreateTeam(ctx context.Context, input usecase.CreateTeamInput) (domain.Team, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTeam", ctx, input) + ret0, _ := ret[0].(domain.Team) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateTeam indicates an expected call of CreateTeam. +func (mr *MockUseCaseMockRecorder) CreateTeam(ctx, input any) *MockUseCaseCreateTeamCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeam", reflect.TypeOf((*MockUseCase)(nil).CreateTeam), ctx, input) + return &MockUseCaseCreateTeamCall{Call: call} +} + +// MockUseCaseCreateTeamCall wrap *gomock.Call +type MockUseCaseCreateTeamCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockUseCaseCreateTeamCall) Return(arg0 domain.Team, arg1 error) *MockUseCaseCreateTeamCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockUseCaseCreateTeamCall) Do(f func(context.Context, usecase.CreateTeamInput) (domain.Team, error)) *MockUseCaseCreateTeamCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockUseCaseCreateTeamCall) DoAndReturn(f func(context.Context, usecase.CreateTeamInput) (domain.Team, error)) *MockUseCaseCreateTeamCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// GetTeam mocks base method. +func (m *MockUseCase) GetTeam(ctx context.Context, id uuid.UUID) (domain.Team, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTeam", ctx, id) + ret0, _ := ret[0].(domain.Team) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTeam indicates an expected call of GetTeam. +func (mr *MockUseCaseMockRecorder) GetTeam(ctx, id any) *MockUseCaseGetTeamCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeam", reflect.TypeOf((*MockUseCase)(nil).GetTeam), ctx, id) + return &MockUseCaseGetTeamCall{Call: call} +} + +// MockUseCaseGetTeamCall wrap *gomock.Call +type MockUseCaseGetTeamCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockUseCaseGetTeamCall) Return(arg0 domain.Team, arg1 error) *MockUseCaseGetTeamCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockUseCaseGetTeamCall) Do(f func(context.Context, uuid.UUID) (domain.Team, error)) *MockUseCaseGetTeamCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockUseCaseGetTeamCall) DoAndReturn(f func(context.Context, uuid.UUID) (domain.Team, error)) *MockUseCaseGetTeamCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// GetTeams mocks base method. +func (m *MockUseCase) GetTeams(ctx context.Context) ([]domain.Team, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTeams", ctx) + ret0, _ := ret[0].([]domain.Team) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTeams indicates an expected call of GetTeams. +func (mr *MockUseCaseMockRecorder) GetTeams(ctx any) *MockUseCaseGetTeamsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeams", reflect.TypeOf((*MockUseCase)(nil).GetTeams), ctx) + return &MockUseCaseGetTeamsCall{Call: call} +} + +// MockUseCaseGetTeamsCall wrap *gomock.Call +type MockUseCaseGetTeamsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockUseCaseGetTeamsCall) Return(arg0 []domain.Team, arg1 error) *MockUseCaseGetTeamsCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockUseCaseGetTeamsCall) Do(f func(context.Context) ([]domain.Team, error)) *MockUseCaseGetTeamsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockUseCaseGetTeamsCall) DoAndReturn(f func(context.Context) ([]domain.Team, error)) *MockUseCaseGetTeamsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UpdateTeam mocks base method. +func (m *MockUseCase) UpdateTeam(ctx context.Context, input usecase.UpdateTeamInput) (domain.Team, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTeam", ctx, input) + ret0, _ := ret[0].(domain.Team) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateTeam indicates an expected call of UpdateTeam. +func (mr *MockUseCaseMockRecorder) UpdateTeam(ctx, input any) *MockUseCaseUpdateTeamCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTeam", reflect.TypeOf((*MockUseCase)(nil).UpdateTeam), ctx, input) + return &MockUseCaseUpdateTeamCall{Call: call} +} + +// MockUseCaseUpdateTeamCall wrap *gomock.Call +type MockUseCaseUpdateTeamCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockUseCaseUpdateTeamCall) Return(arg0 domain.Team, arg1 error) *MockUseCaseUpdateTeamCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockUseCaseUpdateTeamCall) Do(f func(context.Context, usecase.UpdateTeamInput) (domain.Team, error)) *MockUseCaseUpdateTeamCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockUseCaseUpdateTeamCall) DoAndReturn(f func(context.Context, usecase.UpdateTeamInput) (domain.Team, error)) *MockUseCaseUpdateTeamCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/server/usecase/team.go b/server/usecase/team.go new file mode 100644 index 0000000..e8307d5 --- /dev/null +++ b/server/usecase/team.go @@ -0,0 +1,112 @@ +package usecase + +import ( + "context" + "errors" + "slices" + + "github.com/google/uuid" + "github.com/traPtitech/piscon-portal-v2/server/domain" + "github.com/traPtitech/piscon-portal-v2/server/repository" +) + +type TeamUseCase interface { + GetTeams(ctx context.Context) ([]domain.Team, error) + GetTeam(ctx context.Context, id uuid.UUID) (domain.Team, error) + CreateTeam(ctx context.Context, input CreateTeamInput) (domain.Team, error) + UpdateTeam(ctx context.Context, input UpdateTeamInput) (domain.Team, error) +} + +type teamUseCaseImpl struct { + repo repository.Repository +} + +func newTeamUseCase(repo repository.Repository) *teamUseCaseImpl { + return &teamUseCaseImpl{ + repo: repo, + } +} + +func (u *teamUseCaseImpl) GetTeams(ctx context.Context) ([]domain.Team, error) { + return u.repo.GetTeams(ctx) +} + +func (u *teamUseCaseImpl) GetTeam(ctx context.Context, id uuid.UUID) (domain.Team, error) { + team, err := u.repo.FindTeam(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return domain.Team{}, ErrNotFound + } + return domain.Team{}, err + } + return team, nil +} + +type CreateTeamInput struct { + Name string + MemberIDs []uuid.UUID + // CreatorID is the ID of the user who creates the team + CreatorID uuid.UUID +} + +func (u *teamUseCaseImpl) CreateTeam(ctx context.Context, input CreateTeamInput) (domain.Team, error) { + // creator must be a member of the team + isMember := slices.Contains(input.MemberIDs, input.CreatorID) + if !isMember { + return domain.Team{}, NewUseCaseErrorFromMsg("creator must be a member of the team") + } + + team := domain.NewTeam(input.Name) + for _, memberID := range input.MemberIDs { + user, err := u.repo.FindUser(ctx, memberID) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return domain.Team{}, NewUseCaseErrorFromMsg("member not found") + } + return domain.Team{}, err + } + if err := team.AddMember(user); err != nil { + return domain.Team{}, NewUseCaseError(err) + } + } + + if err := u.repo.CreateTeam(ctx, team); err != nil { + return domain.Team{}, err + } + + return team, nil +} + +type UpdateTeamInput struct { + ID uuid.UUID + Name string + MemberIDs []uuid.UUID +} + +func (u *teamUseCaseImpl) UpdateTeam(ctx context.Context, input UpdateTeamInput) (domain.Team, error) { + team, err := u.repo.FindTeam(ctx, input.ID) + if err != nil { + return domain.Team{}, err + } + + if input.Name != "" { + team.Name = input.Name + } + + for _, memberID := range input.MemberIDs { + user, err := u.repo.FindUser(ctx, memberID) + if err != nil { + return domain.Team{}, err + } + if err := team.AddMember(user); err != nil { + return domain.Team{}, err + } + } + + err = u.repo.UpdateTeam(ctx, team) + if err != nil { + return domain.Team{}, err + } + + return team, nil +} diff --git a/server/usecase/team_test.go b/server/usecase/team_test.go new file mode 100644 index 0000000..dac3cff --- /dev/null +++ b/server/usecase/team_test.go @@ -0,0 +1,194 @@ +package usecase_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/traPtitech/piscon-portal-v2/server/domain" + "github.com/traPtitech/piscon-portal-v2/server/repository" + "github.com/traPtitech/piscon-portal-v2/server/repository/mock" + "github.com/traPtitech/piscon-portal-v2/server/usecase" + "go.uber.org/mock/gomock" +) + +func TestCreateTeam(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + + mockRepo := mock.NewMockRepository(ctrl) + useCase := usecase.New(mockRepo) + + userID := uuid.New() + + tests := []struct { + name string + input usecase.CreateTeamInput + expectError bool + setup func() + }{ + { + name: "valid", + input: usecase.CreateTeamInput{ + Name: "valid-test-team", + MemberIDs: []uuid.UUID{userID}, + CreatorID: userID, + }, + expectError: false, + setup: func() { + mockRepo.EXPECT().FindUser(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id uuid.UUID) (domain.User, error) { + return domain.User{ID: id}, nil + }) + mockRepo.EXPECT().CreateTeam(gomock.Any(), gomock.Any()) + }, + }, + { + name: "multiple members", + input: usecase.CreateTeamInput{ + Name: "multiple-members-test-team", + MemberIDs: []uuid.UUID{userID, uuid.New()}, + CreatorID: userID, + }, + expectError: false, + setup: func() { + mockRepo.EXPECT().FindUser(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id uuid.UUID) (domain.User, error) { + return domain.User{ID: id}, nil + }).Times(2) + mockRepo.EXPECT().CreateTeam(gomock.Any(), gomock.Any()) + }, + }, + { + name: "more than 3 members team is not allowed", + input: usecase.CreateTeamInput{ + Name: "4-members-test-team", + MemberIDs: []uuid.UUID{userID, uuid.New(), uuid.New(), uuid.New()}, + CreatorID: userID, + }, + expectError: true, + setup: func() { + mockRepo.EXPECT().FindUser(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id uuid.UUID) (domain.User, error) { + return domain.User{ID: id}, nil + }).Times(4) + }, + }, + { + name: "team member not found", + input: usecase.CreateTeamInput{ + Name: "user-not-found-test-team", + MemberIDs: []uuid.UUID{userID}, + CreatorID: userID, + }, + expectError: true, + setup: func() { + mockRepo.EXPECT().FindUser(gomock.Any(), gomock.Any()). + Return(domain.User{}, repository.ErrNotFound) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup() + _, err := useCase.CreateTeam(context.Background(), tt.input) + if err != nil && !tt.expectError { + t.Errorf("unexpected error: %v", err) + } else if err == nil && tt.expectError { + t.Error("expected error, but got nil") + } + }) + } +} + +func TestUpdateTeam(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + + mockRepo := mock.NewMockRepository(ctrl) + useCase := usecase.New(mockRepo) + + userID := uuid.New() + + tests := []struct { + name string + input usecase.UpdateTeamInput + expectError bool + setup func() + }{ + { + name: "valid", + input: usecase.UpdateTeamInput{ + ID: uuid.New(), + Name: "valid-test-team", + MemberIDs: []uuid.UUID{userID}, + }, + expectError: false, + setup: func() { + mockRepo.EXPECT().FindTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id uuid.UUID) (domain.Team, error) { + return domain.Team{ + ID: id, + Members: []domain.User{{ID: userID, TeamID: uuid.NullUUID{UUID: id, Valid: true}}}, + }, nil + }) + mockRepo.EXPECT().FindUser(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id uuid.UUID) (domain.User, error) { + return domain.User{ID: id}, nil + }) + mockRepo.EXPECT().UpdateTeam(gomock.Any(), gomock.Any()) + }, + }, + { + name: "more than 3 members team is not allowed", + input: usecase.UpdateTeamInput{ + ID: uuid.New(), + Name: "4-members-test-team", + MemberIDs: []uuid.UUID{userID, uuid.New(), uuid.New(), uuid.New()}, + }, + expectError: true, + setup: func() { + mockRepo.EXPECT().FindTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id uuid.UUID) (domain.Team, error) { + return domain.Team{ID: id}, nil + }) + mockRepo.EXPECT().FindUser(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id uuid.UUID) (domain.User, error) { + return domain.User{ID: id}, nil + }).Times(4) + }, + }, + { + name: "team member not found", + input: usecase.UpdateTeamInput{ + ID: uuid.New(), + Name: "user-not-found-test-team", + MemberIDs: []uuid.UUID{userID}, + }, + expectError: true, + setup: func() { + mockRepo.EXPECT().FindTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, id uuid.UUID) (domain.Team, error) { + return domain.Team{ID: id}, nil + }) + mockRepo.EXPECT().FindUser(gomock.Any(), gomock.Any()). + Return(domain.User{}, repository.ErrNotFound) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup() + _, err := useCase.UpdateTeam(context.Background(), tt.input) + if err != nil && !tt.expectError { + t.Errorf("unexpected error: %v", err) + } else if err == nil && tt.expectError { + t.Error("expected error, but got nil") + } + }) + } +} diff --git a/server/usecase/usecase.go b/server/usecase/usecase.go new file mode 100644 index 0000000..537e0c5 --- /dev/null +++ b/server/usecase/usecase.go @@ -0,0 +1,18 @@ +package usecase + +import "github.com/traPtitech/piscon-portal-v2/server/repository" + +//go:generate go run go.uber.org/mock/mockgen@v0.5.0 -source=$GOFILE -destination=mock/$GOFILE -package=mock -typed=true +type UseCase interface { + TeamUseCase +} + +type useCaseImpl struct { + TeamUseCase +} + +func New(repo repository.Repository) UseCase { + return &useCaseImpl{ + TeamUseCase: newTeamUseCase(repo), + } +}