diff --git a/.github/workflows/srv.test.yaml b/.github/workflows/srv.test.yaml index 86c22a0..45a361f 100644 --- a/.github/workflows/srv.test.yaml +++ b/.github/workflows/srv.test.yaml @@ -11,7 +11,8 @@ on: default: '.' jobs: - test: + integration-test: + name: Run integration tests runs-on: ubuntu-latest defaults: run: @@ -33,5 +34,25 @@ jobs: cache-dependency-path: ${{ inputs.working_directory}}/go.sum - name: Install gotestsum run: go install gotest.tools/gotestsum@latest + - name: Install tern + run: go install github.com/jackc/tern/v2@latest + - name: Run migrations + run: tern migrate -m migrations -c migrations/tern.conf - name: Run tests - run: gotestsum --format pkgname-and-test-fails -- -race --count=1 ./... + run: gotestsum --format pkgname-and-test-fails -- -race --count=1 --tags=integration ./... + unit-test: + name: Run unit tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ inputs.working_directory}} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go_version }} + cache-dependency-path: ${{ inputs.working_directory}}/go.sum + - name: Install gotestsum + run: go install gotest.tools/gotestsum@latest + - name: Run tests + run: gotestsum --format pkgname-and-test-fails -- -race --count=1 --tags=unit ./... diff --git a/apps/srv/.mockery.yml b/apps/srv/.mockery.yml new file mode 100644 index 0000000..333faf0 --- /dev/null +++ b/apps/srv/.mockery.yml @@ -0,0 +1,17 @@ +all: false +dir: '{{.InterfaceDir}}/mocks' +filename: '{{.InterfaceName}}.go' +force-file-write: true +formatter: goimports +log-level: info +structname: '{{.InterfaceName}}' +pkgname: 'mocks' +recursive: false +require-template-schema-exists: true +template: testify +template-schema: '{{.Template}}.schema.json' +packages: + github.com/arthurdotwork/bastion/internal/domain: + config: + recursive: true + all: true diff --git a/apps/srv/Taskfile.yaml b/apps/srv/Taskfile.yaml index 7962ace..504baa1 100644 --- a/apps/srv/Taskfile.yaml +++ b/apps/srv/Taskfile.yaml @@ -19,15 +19,28 @@ tasks: test:install: desc: Install the test runner status: - - gotestsum version + - gotestsum --version cmds: - go install gotest.tools/gotestsum@latest test: desc: Run the tests + cmds: + - task: test:unit + - task: test:integration + + test:unit: + desc: Run the unit tests + deps: + - test:install + cmds: + - gotestsum --format pkgname-and-test-fails -- -race --count=1 --tags unit ./... + + test:integration: + desc: Run the integration tests deps: - test:install cmds: - - gotestsum --format pkgname-and-test-fails -- -race --count=1 ./... + - gotestsum --format pkgname-and-test-fails -- -race --count=1 --tags integration ./... build: desc: Build the project diff --git a/apps/srv/go.mod b/apps/srv/go.mod index 9cfc2b3..c7e6af2 100644 --- a/apps/srv/go.mod +++ b/apps/srv/go.mod @@ -10,39 +10,40 @@ require ( github.com/jackc/pgx/v5 v5.7.4 github.com/jmoiron/sqlx v1.4.0 github.com/stretchr/testify v1.10.0 + golang.org/x/crypto v0.37.0 golang.org/x/sync v0.13.0 ) require ( - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect + github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + golang.org/x/arch v0.16.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/srv/go.sum b/apps/srv/go.sum index 9422a94..59c6ec3 100644 --- a/apps/srv/go.sum +++ b/apps/srv/go.sum @@ -6,10 +6,16 @@ github.com/arthurdotwork/alog v1.1.0 h1:B+ZM2ELZRVslpeE0J80CIU9kAotiIPNBpYQcX70v github.com/arthurdotwork/alog v1.1.0/go.mod h1:oMdUMsVNC8iO/gmRV7/Yw3Q3HAQclLgMoZ0tXht69PI= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= +github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,8 +23,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -29,10 +39,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -55,6 +69,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -75,6 +91,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -82,6 +100,7 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -99,22 +118,34 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= +golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= 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/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.5.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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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= diff --git a/apps/srv/internal/adapters/primary/http/handler/register.go b/apps/srv/internal/adapters/primary/http/handler/register.go new file mode 100644 index 0000000..a7f38ad --- /dev/null +++ b/apps/srv/internal/adapters/primary/http/handler/register.go @@ -0,0 +1,35 @@ +package handler + +import ( + "log/slog" + "net/http" + + "github.com/arthurdotwork/bastion/internal/domain/membership" + "github.com/gin-gonic/gin" +) + +type registerRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func Register(rs *membership.RegisterService) gin.HandlerFunc { + return func(context *gin.Context) { + ctx := context.Request.Context() + + var req registerRequest + if err := context.ShouldBindJSON(&req); err != nil { + slog.ErrorContext(ctx, "failed to validate request", slog.Any("error", err)) + context.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + if _, err := rs.Register(ctx, membership.User{Email: req.Email, Password: req.Password}); err != nil { + slog.ErrorContext(ctx, "failed to register user", slog.Any("error", err)) + context.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + context.JSON(http.StatusCreated, gin.H{}) + } +} diff --git a/apps/srv/internal/adapters/secondary/hasher/bcrypt.go b/apps/srv/internal/adapters/secondary/hasher/bcrypt.go new file mode 100644 index 0000000..592f351 --- /dev/null +++ b/apps/srv/internal/adapters/secondary/hasher/bcrypt.go @@ -0,0 +1,26 @@ +package hasher + +import ( + "context" + + "golang.org/x/crypto/bcrypt" +) + +type BcryptHasher struct { + cost int +} + +func NewBcryptHasher(cost int) *BcryptHasher { + return &BcryptHasher{ + cost: cost, + } +} + +func (h *BcryptHasher) Hash(ctx context.Context, password string) (string, error) { + bcryptHash, err := bcrypt.GenerateFromPassword([]byte(password), h.cost) + if err != nil { + return "", err + } + + return string(bcryptHash), nil +} diff --git a/apps/srv/internal/adapters/secondary/hasher/bcrypt_test.go b/apps/srv/internal/adapters/secondary/hasher/bcrypt_test.go new file mode 100644 index 0000000..c49c68b --- /dev/null +++ b/apps/srv/internal/adapters/secondary/hasher/bcrypt_test.go @@ -0,0 +1,38 @@ +//go:build unit + +package hasher_test + +import ( + "context" + "strings" + "testing" + + "github.com/arthurdotwork/bastion/internal/adapters/secondary/hasher" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestBcryptHasher_Hash(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + h := hasher.NewBcryptHasher(bcrypt.DefaultCost) + + t.Run("it should return an error if it can not hash the password", func(t *testing.T) { + t.Parallel() + + _, err := h.Hash(ctx, strings.Repeat("a", 128)) + require.Error(t, err) + }) + + t.Run("it should hash the password", func(t *testing.T) { + t.Parallel() + + hash, err := h.Hash(ctx, "password") + require.NoError(t, err) + require.NotEmpty(t, hash) + require.NotEqual(t, "password", hash) + }) +} diff --git a/apps/srv/internal/adapters/secondary/store/user_store.go b/apps/srv/internal/adapters/secondary/store/user_store.go new file mode 100644 index 0000000..ace2f57 --- /dev/null +++ b/apps/srv/internal/adapters/secondary/store/user_store.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/arthurdotwork/bastion/internal/domain/membership" + "github.com/arthurdotwork/bastion/internal/infra/psql" + "github.com/arthurdotwork/bastion/internal/infra/queries" +) + +type UserStore struct { + db psql.Queryable + q *queries.Queries +} + +func NewUserStore(db psql.Queryable, q *queries.Queries) *UserStore { + return &UserStore{ + db: db, + q: q, + } +} + +func (s *UserStore) Atomic(ctx context.Context, fn func(ctx context.Context, userStore membership.UserStore) error) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + q := s.q.WithTx(tx.Tx().Tx) + userStore := NewUserStore(tx, q) + if err := fn(ctx, userStore); err != nil { + _ = tx.Rollback() //nolint:errcheck + return fmt.Errorf("transaction failed: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} + +func (s *UserStore) CreateUser(ctx context.Context, user membership.User) (membership.User, error) { + createUserParams := queries.CreateUserParams{ + Email: user.Email, + Password: user.Password, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + createdUser, err := s.q.CreateUser(ctx, createUserParams) + if err != nil { + return membership.User{}, fmt.Errorf("failed to create user: %w", err) + } + + return membership.User{ + ID: createdUser.ID, + Email: createdUser.Email, + Password: createdUser.Password, + Username: createdUser.Username, + CreatedAt: createdUser.CreatedAt, + UpdatedAt: createdUser.UpdatedAt, + DeletedAt: nil, + }, nil +} + +func (s *UserStore) GetUserByEmail(ctx context.Context, email string) (membership.User, error) { + getUserByEmailParams := queries.GetUserByEmailParams{Email: email} + + user, err := s.q.GetUserByEmail(ctx, getUserByEmailParams) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return membership.User{}, nil + } + + return membership.User{}, fmt.Errorf("failed to get user by email: %w", err) + } + + var deletedAt *time.Time + if user.DeletedAt.Valid { + deletedAt = &user.DeletedAt.Time + } + + return membership.User{ + ID: user.ID, + Email: user.Email, + Password: user.Password, + Username: user.Username, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + DeletedAt: deletedAt, + }, nil +} diff --git a/apps/srv/internal/adapters/secondary/store/user_store_test.go b/apps/srv/internal/adapters/secondary/store/user_store_test.go new file mode 100644 index 0000000..8f20db6 --- /dev/null +++ b/apps/srv/internal/adapters/secondary/store/user_store_test.go @@ -0,0 +1,59 @@ +//go:build integration + +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/arthurdotwork/bastion/internal/adapters/secondary/store" + "github.com/arthurdotwork/bastion/internal/domain/membership" + "github.com/arthurdotwork/bastion/internal/infra/psql" + "github.com/arthurdotwork/bastion/internal/infra/queries" + "github.com/stretchr/testify/require" +) + +func TestUserStore_CreateUser(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + db, err := psql.Connect(ctx, "postgres", "postgres", "localhost", "5432", "postgres") + require.NoError(t, err) + + tx, err := db.BeginTxx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() //nolint:errcheck + + q := queries.New(tx.Tx()) + userStore := store.NewUserStore(tx, q) + + t.Run("it should create the user", func(t *testing.T) { + user := membership.User{ + Email: "email@bastion.dev", + Password: "password", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + createdUser, err := userStore.CreateUser(ctx, user) + require.NoError(t, err) + require.NotEmpty(t, createdUser.ID) + require.EqualValues(t, user.Email, createdUser.Email) + require.EqualValues(t, user.Password, createdUser.Password) + require.WithinDuration(t, user.CreatedAt, createdUser.CreatedAt, time.Second) + require.WithinDuration(t, user.UpdatedAt, createdUser.UpdatedAt, time.Second) + require.Nil(t, createdUser.DeletedAt) + + t.Run("it should return an error if the user already exists", func(t *testing.T) { + _, err := userStore.CreateUser(ctx, user) + require.Error(t, err) + }) + }) +} + +func TestUserStore_GetUserByEmail(t *testing.T) { + +} diff --git a/apps/srv/internal/domain/membership/contracts.go b/apps/srv/internal/domain/membership/contracts.go new file mode 100644 index 0000000..6b729eb --- /dev/null +++ b/apps/srv/internal/domain/membership/contracts.go @@ -0,0 +1,13 @@ +package membership + +import "context" + +type UserStore interface { + Atomic(ctx context.Context, fn func(ctx context.Context, userStore UserStore) error) error + CreateUser(ctx context.Context, user User) (User, error) + GetUserByEmail(ctx context.Context, email string) (User, error) +} + +type Hasher interface { + Hash(ctx context.Context, password string) (string, error) +} diff --git a/apps/srv/internal/domain/membership/entity.go b/apps/srv/internal/domain/membership/entity.go new file mode 100644 index 0000000..e812109 --- /dev/null +++ b/apps/srv/internal/domain/membership/entity.go @@ -0,0 +1,17 @@ +package membership + +import ( + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID + Email string + Password string + Username string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} diff --git a/apps/srv/internal/domain/membership/mocks/Hasher.go b/apps/srv/internal/domain/membership/mocks/Hasher.go new file mode 100644 index 0000000..62dfbf0 --- /dev/null +++ b/apps/srv/internal/domain/membership/mocks/Hasher.go @@ -0,0 +1,93 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + mock "github.com/stretchr/testify/mock" +) + +// NewHasher creates a new instance of Hasher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewHasher(t interface { + mock.TestingT + Cleanup(func()) +}) *Hasher { + mock := &Hasher{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// Hasher is an autogenerated mock type for the Hasher type +type Hasher struct { + mock.Mock +} + +type Hasher_Expecter struct { + mock *mock.Mock +} + +func (_m *Hasher) EXPECT() *Hasher_Expecter { + return &Hasher_Expecter{mock: &_m.Mock} +} + +// Hash provides a mock function for the type Hasher +func (_mock *Hasher) Hash(ctx context.Context, password string) (string, error) { + ret := _mock.Called(ctx, password) + + if len(ret) == 0 { + panic("no return value specified for Hash") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { + return returnFunc(ctx, password) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = returnFunc(ctx, password) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, password) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Hasher_Hash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hash' +type Hasher_Hash_Call struct { + *mock.Call +} + +// Hash is a helper method to define mock.On call +// - ctx +// - password +func (_e *Hasher_Expecter) Hash(ctx interface{}, password interface{}) *Hasher_Hash_Call { + return &Hasher_Hash_Call{Call: _e.mock.On("Hash", ctx, password)} +} + +func (_c *Hasher_Hash_Call) Run(run func(ctx context.Context, password string)) *Hasher_Hash_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Hasher_Hash_Call) Return(s string, err error) *Hasher_Hash_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *Hasher_Hash_Call) RunAndReturn(run func(ctx context.Context, password string) (string, error)) *Hasher_Hash_Call { + _c.Call.Return(run) + return _c +} diff --git a/apps/srv/internal/domain/membership/mocks/UserStore.go b/apps/srv/internal/domain/membership/mocks/UserStore.go new file mode 100644 index 0000000..b1c64c0 --- /dev/null +++ b/apps/srv/internal/domain/membership/mocks/UserStore.go @@ -0,0 +1,195 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/arthurdotwork/bastion/internal/domain/membership" + mock "github.com/stretchr/testify/mock" +) + +// NewUserStore creates a new instance of UserStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewUserStore(t interface { + mock.TestingT + Cleanup(func()) +}) *UserStore { + mock := &UserStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// UserStore is an autogenerated mock type for the UserStore type +type UserStore struct { + mock.Mock +} + +type UserStore_Expecter struct { + mock *mock.Mock +} + +func (_m *UserStore) EXPECT() *UserStore_Expecter { + return &UserStore_Expecter{mock: &_m.Mock} +} + +// Atomic provides a mock function for the type UserStore +func (_mock *UserStore) Atomic(ctx context.Context, fn func(ctx context.Context, userStore membership.UserStore) error) error { + ret := _mock.Called(ctx, fn) + + if len(ret) == 0 { + panic("no return value specified for Atomic") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, func(ctx context.Context, userStore membership.UserStore) error) error); ok { + r0 = returnFunc(ctx, fn) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// UserStore_Atomic_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Atomic' +type UserStore_Atomic_Call struct { + *mock.Call +} + +// Atomic is a helper method to define mock.On call +// - ctx +// - fn +func (_e *UserStore_Expecter) Atomic(ctx interface{}, fn interface{}) *UserStore_Atomic_Call { + return &UserStore_Atomic_Call{Call: _e.mock.On("Atomic", ctx, fn)} +} + +func (_c *UserStore_Atomic_Call) Run(run func(ctx context.Context, fn func(ctx context.Context, userStore membership.UserStore) error)) *UserStore_Atomic_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(func(ctx context.Context, userStore membership.UserStore) error)) + }) + return _c +} + +func (_c *UserStore_Atomic_Call) Return(err error) *UserStore_Atomic_Call { + _c.Call.Return(err) + return _c +} + +func (_c *UserStore_Atomic_Call) RunAndReturn(run func(ctx context.Context, fn func(ctx context.Context, userStore membership.UserStore) error) error) *UserStore_Atomic_Call { + _c.Call.Return(run) + return _c +} + +// CreateUser provides a mock function for the type UserStore +func (_mock *UserStore) CreateUser(ctx context.Context, user membership.User) (membership.User, error) { + ret := _mock.Called(ctx, user) + + if len(ret) == 0 { + panic("no return value specified for CreateUser") + } + + var r0 membership.User + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, membership.User) (membership.User, error)); ok { + return returnFunc(ctx, user) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, membership.User) membership.User); ok { + r0 = returnFunc(ctx, user) + } else { + r0 = ret.Get(0).(membership.User) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, membership.User) error); ok { + r1 = returnFunc(ctx, user) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// UserStore_CreateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUser' +type UserStore_CreateUser_Call struct { + *mock.Call +} + +// CreateUser is a helper method to define mock.On call +// - ctx +// - user +func (_e *UserStore_Expecter) CreateUser(ctx interface{}, user interface{}) *UserStore_CreateUser_Call { + return &UserStore_CreateUser_Call{Call: _e.mock.On("CreateUser", ctx, user)} +} + +func (_c *UserStore_CreateUser_Call) Run(run func(ctx context.Context, user membership.User)) *UserStore_CreateUser_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(membership.User)) + }) + return _c +} + +func (_c *UserStore_CreateUser_Call) Return(user1 membership.User, err error) *UserStore_CreateUser_Call { + _c.Call.Return(user1, err) + return _c +} + +func (_c *UserStore_CreateUser_Call) RunAndReturn(run func(ctx context.Context, user membership.User) (membership.User, error)) *UserStore_CreateUser_Call { + _c.Call.Return(run) + return _c +} + +// GetUserByEmail provides a mock function for the type UserStore +func (_mock *UserStore) GetUserByEmail(ctx context.Context, email string) (membership.User, error) { + ret := _mock.Called(ctx, email) + + if len(ret) == 0 { + panic("no return value specified for GetUserByEmail") + } + + var r0 membership.User + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (membership.User, error)); ok { + return returnFunc(ctx, email) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) membership.User); ok { + r0 = returnFunc(ctx, email) + } else { + r0 = ret.Get(0).(membership.User) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, email) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// UserStore_GetUserByEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByEmail' +type UserStore_GetUserByEmail_Call struct { + *mock.Call +} + +// GetUserByEmail is a helper method to define mock.On call +// - ctx +// - email +func (_e *UserStore_Expecter) GetUserByEmail(ctx interface{}, email interface{}) *UserStore_GetUserByEmail_Call { + return &UserStore_GetUserByEmail_Call{Call: _e.mock.On("GetUserByEmail", ctx, email)} +} + +func (_c *UserStore_GetUserByEmail_Call) Run(run func(ctx context.Context, email string)) *UserStore_GetUserByEmail_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *UserStore_GetUserByEmail_Call) Return(user membership.User, err error) *UserStore_GetUserByEmail_Call { + _c.Call.Return(user, err) + return _c +} + +func (_c *UserStore_GetUserByEmail_Call) RunAndReturn(run func(ctx context.Context, email string) (membership.User, error)) *UserStore_GetUserByEmail_Call { + _c.Call.Return(run) + return _c +} diff --git a/apps/srv/internal/domain/membership/register_service.go b/apps/srv/internal/domain/membership/register_service.go new file mode 100644 index 0000000..fd756e9 --- /dev/null +++ b/apps/srv/internal/domain/membership/register_service.go @@ -0,0 +1,48 @@ +package membership + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" +) + +type RegisterService struct { + userStore UserStore + Hasher Hasher +} + +func NewRegisterService(userStore UserStore, hasher Hasher) *RegisterService { + return &RegisterService{ + userStore: userStore, + Hasher: hasher, + } +} + +func (s *RegisterService) Register(ctx context.Context, user User) (User, error) { + existingUser, err := s.userStore.GetUserByEmail(ctx, user.Email) + if err != nil { + return User{}, fmt.Errorf("failed to check if user exists by email: %w", err) + } + + // the user exists... + if existingUser.ID != uuid.Nil { + return existingUser, nil + } + + hashedPassword, err := s.Hasher.Hash(ctx, user.Password) + if err != nil { + return User{}, fmt.Errorf("failed to hash password: %w", err) + } + + user.Password = hashedPassword + user.CreatedAt = time.Now().UTC() + user.UpdatedAt = time.Now().UTC() + createdUser, err := s.userStore.CreateUser(ctx, user) + if err != nil { + return User{}, fmt.Errorf("failed to create user: %w", err) + } + + return createdUser, nil +} diff --git a/apps/srv/internal/domain/membership/register_service_test.go b/apps/srv/internal/domain/membership/register_service_test.go new file mode 100644 index 0000000..6238f91 --- /dev/null +++ b/apps/srv/internal/domain/membership/register_service_test.go @@ -0,0 +1,69 @@ +//go:build unit + +package membership_test + +import ( + "context" + "testing" + + "github.com/arthurdotwork/bastion/internal/domain/membership" + "github.com/arthurdotwork/bastion/internal/domain/membership/mocks" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRegisterService_Register(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + userStore := mocks.NewUserStore(t) + hasher := mocks.NewHasher(t) + registerService := membership.NewRegisterService(userStore, hasher) + + t.Run("it should return an error if it can not retrieve the user", func(t *testing.T) { + userStore.EXPECT().GetUserByEmail(ctx, "email@bastion.dev").Return(membership.User{}, assert.AnError).Once() + + _, err := registerService.Register(ctx, membership.User{Email: "email@bastion.dev"}) + require.Error(t, err) + }) + + t.Run("it should return the existing user if it exists", func(t *testing.T) { + existingUser := membership.User{ID: uuid.New()} + userStore.EXPECT().GetUserByEmail(ctx, "email@bastion.dev").Return(existingUser, nil).Once() + + user, err := registerService.Register(ctx, membership.User{Email: "email@bastion.dev"}) + require.NoError(t, err) + require.Equal(t, existingUser.ID, user.ID) + }) + + t.Run("it should return an error if it can not hash the password", func(t *testing.T) { + userStore.EXPECT().GetUserByEmail(ctx, "email@bastion.dev").Return(membership.User{}, nil).Once() + hasher.EXPECT().Hash(ctx, "password").Return("", assert.AnError).Once() + + _, err := registerService.Register(ctx, membership.User{Email: "email@bastion.dev", Password: "password"}) + require.Error(t, err) + }) + + t.Run("it should return an error if it can not create the user", func(t *testing.T) { + userStore.EXPECT().GetUserByEmail(ctx, "email@bastion.dev").Return(membership.User{}, nil).Once() + hasher.EXPECT().Hash(ctx, "password").Return("hashedPassword", nil).Once() + userStore.EXPECT().CreateUser(ctx, mock.Anything).Return(membership.User{}, assert.AnError).Once() + + _, err := registerService.Register(ctx, membership.User{Email: "email@bastion.dev", Password: "password"}) + require.Error(t, err) + }) + + t.Run("it should create the user", func(t *testing.T) { + userStore.EXPECT().GetUserByEmail(ctx, "email@bastion.dev").Return(membership.User{}, nil).Once() + hasher.EXPECT().Hash(ctx, "password").Return("hashedPassword", nil).Once() + userStore.EXPECT().CreateUser(ctx, mock.Anything).Return(membership.User{ID: uuid.New()}, nil).Once() + + user, err := registerService.Register(ctx, membership.User{Email: "email@bastion.dev", Password: "password"}) + require.NoError(t, err) + require.NotEmpty(t, user.ID) + }) +} diff --git a/apps/srv/internal/infra/container/container.go b/apps/srv/internal/infra/container/container.go index c194379..b132b04 100644 --- a/apps/srv/internal/infra/container/container.go +++ b/apps/srv/internal/infra/container/container.go @@ -9,9 +9,15 @@ import ( "strings" "sync" + "github.com/arthurdotwork/bastion/internal/adapters/primary/http/handler" + "github.com/arthurdotwork/bastion/internal/adapters/secondary/hasher" + "github.com/arthurdotwork/bastion/internal/adapters/secondary/store" + "github.com/arthurdotwork/bastion/internal/domain/membership" "github.com/arthurdotwork/bastion/internal/infra/http" "github.com/arthurdotwork/bastion/internal/infra/psql" "github.com/arthurdotwork/bastion/internal/infra/queries" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" ) type Container struct { @@ -71,6 +77,30 @@ func (c *Container) SetupHTTPServer() *http.Server { }) } +func (c *Container) SetupRegisterHandler() gin.HandlerFunc { + return singleton(c, func() gin.HandlerFunc { + return handler.Register(c.SetupMembershipRegisterService()) + }) +} + +func (c *Container) SetupUserStore() *store.UserStore { + return singleton(c, func() *store.UserStore { + return store.NewUserStore(c.SetupDatabase(), c.SetupQueries()) + }) +} + +func (c *Container) SetupBcryptHasher() *hasher.BcryptHasher { + return singleton(c, func() *hasher.BcryptHasher { + return hasher.NewBcryptHasher(bcrypt.DefaultCost) + }) +} + +func (c *Container) SetupMembershipRegisterService() *membership.RegisterService { + return singleton(c, func() *membership.RegisterService { + return membership.NewRegisterService(c.SetupUserStore(), c.SetupBcryptHasher()) + }) +} + func (c *Container) Shutdown() { for _, shutdownFunc := range c.shutdownFuncs { if err := shutdownFunc(); err != nil { diff --git a/apps/srv/internal/infra/http/server_test.go b/apps/srv/internal/infra/http/server_test.go index 1f8c31c..889ba01 100644 --- a/apps/srv/internal/infra/http/server_test.go +++ b/apps/srv/internal/infra/http/server_test.go @@ -1,3 +1,5 @@ +//go:build integration + package http_test import ( diff --git a/apps/srv/internal/infra/psql/psql_test.go b/apps/srv/internal/infra/psql/psql_test.go index 2d3f51a..b31c9cc 100644 --- a/apps/srv/internal/infra/psql/psql_test.go +++ b/apps/srv/internal/infra/psql/psql_test.go @@ -1,3 +1,5 @@ +//go:build integration + package psql_test import ( diff --git a/apps/srv/internal/infra/queries/db.go b/apps/srv/internal/infra/queries/db.go index cf886f3..cbfb6af 100644 --- a/apps/srv/internal/infra/queries/db.go +++ b/apps/srv/internal/infra/queries/db.go @@ -24,17 +24,25 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error - if q.getUUIDStmt, err = db.PrepareContext(ctx, getUUID); err != nil { - return nil, fmt.Errorf("error preparing query GetUUID: %w", err) + if q.createUserStmt, err = db.PrepareContext(ctx, createUser); err != nil { + return nil, fmt.Errorf("error preparing query CreateUser: %w", err) + } + if q.getUserByEmailStmt, err = db.PrepareContext(ctx, getUserByEmail); err != nil { + return nil, fmt.Errorf("error preparing query GetUserByEmail: %w", err) } return &q, nil } func (q *Queries) Close() error { var err error - if q.getUUIDStmt != nil { - if cerr := q.getUUIDStmt.Close(); cerr != nil { - err = fmt.Errorf("error closing getUUIDStmt: %w", cerr) + if q.createUserStmt != nil { + if cerr := q.createUserStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createUserStmt: %w", cerr) + } + } + if q.getUserByEmailStmt != nil { + if cerr := q.getUserByEmailStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getUserByEmailStmt: %w", cerr) } } return err @@ -74,15 +82,17 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - getUUIDStmt *sql.Stmt + db DBTX + tx *sql.Tx + createUserStmt *sql.Stmt + getUserByEmailStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - getUUIDStmt: q.getUUIDStmt, + db: tx, + tx: tx, + createUserStmt: q.createUserStmt, + getUserByEmailStmt: q.getUserByEmailStmt, } } diff --git a/apps/srv/internal/infra/queries/models.go b/apps/srv/internal/infra/queries/models.go index cf3f817..3df418c 100644 --- a/apps/srv/internal/infra/queries/models.go +++ b/apps/srv/internal/infra/queries/models.go @@ -3,3 +3,20 @@ // sqlc v1.27.0 package queries + +import ( + "database/sql" + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} diff --git a/apps/srv/internal/infra/queries/querier.go b/apps/srv/internal/infra/queries/querier.go index d4b161b..8c78722 100644 --- a/apps/srv/internal/infra/queries/querier.go +++ b/apps/srv/internal/infra/queries/querier.go @@ -6,12 +6,11 @@ package queries import ( "context" - - "github.com/google/uuid" ) type Querier interface { - GetUUID(ctx context.Context) (uuid.UUID, error) + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) + GetUserByEmail(ctx context.Context, arg GetUserByEmailParams) (User, error) } var _ Querier = (*Queries)(nil) diff --git a/apps/srv/internal/infra/queries/sql/user.sql b/apps/srv/internal/infra/queries/sql/user.sql new file mode 100644 index 0000000..d471487 --- /dev/null +++ b/apps/srv/internal/infra/queries/sql/user.sql @@ -0,0 +1,11 @@ +-- name: GetUserByEmail :one +SELECT * +FROM users +WHERE true + AND email = @email + AND deleted_at IS NULL; + +-- name: CreateUser :one +INSERT INTO users (username, email, password, created_at, updated_at) +VALUES (@username, @email, @password, @created_at, @updated_at) +RETURNING *; diff --git a/apps/srv/internal/infra/queries/sql/uuid.sql b/apps/srv/internal/infra/queries/sql/uuid.sql deleted file mode 100644 index cb08576..0000000 --- a/apps/srv/internal/infra/queries/sql/uuid.sql +++ /dev/null @@ -1,2 +0,0 @@ --- name: GetUUID :one -SELECT gen_random_uuid(); diff --git a/apps/srv/internal/infra/queries/user.sql.go b/apps/srv/internal/infra/queries/user.sql.go new file mode 100644 index 0000000..469cb34 --- /dev/null +++ b/apps/srv/internal/infra/queries/user.sql.go @@ -0,0 +1,73 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: user.sql + +package queries + +import ( + "context" + "time" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (username, email, password, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, username, email, password, created_at, updated_at, deleted_at +` + +type CreateUserParams struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.queryRow(ctx, q.createUserStmt, createUser, + arg.Username, + arg.Email, + arg.Password, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Email, + &i.Password, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, username, email, password, created_at, updated_at, deleted_at +FROM users +WHERE true + AND email = $1 + AND deleted_at IS NULL +` + +type GetUserByEmailParams struct { + Email string `json:"email"` +} + +func (q *Queries) GetUserByEmail(ctx context.Context, arg GetUserByEmailParams) (User, error) { + row := q.queryRow(ctx, q.getUserByEmailStmt, getUserByEmail, arg.Email) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Email, + &i.Password, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} diff --git a/apps/srv/internal/infra/queries/uuid.sql.go b/apps/srv/internal/infra/queries/uuid.sql.go deleted file mode 100644 index 14a4602..0000000 --- a/apps/srv/internal/infra/queries/uuid.sql.go +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: uuid.sql - -package queries - -import ( - "context" - - "github.com/google/uuid" -) - -const getUUID = `-- name: GetUUID :one -SELECT gen_random_uuid() -` - -func (q *Queries) GetUUID(ctx context.Context) (uuid.UUID, error) { - row := q.queryRow(ctx, q.getUUIDStmt, getUUID) - var gen_random_uuid uuid.UUID - err := row.Scan(&gen_random_uuid) - return gen_random_uuid, err -} diff --git a/apps/srv/main.go b/apps/srv/main.go index 42bdff6..ec24c09 100644 --- a/apps/srv/main.go +++ b/apps/srv/main.go @@ -53,14 +53,9 @@ func run(parent context.Context) error { g.Go(func() error { defer recover.Recover(ctx) - q := dependencyContainer.SetupQueries() - generatedUUID, err := q.GetUUID(ctx) - if err != nil { - return fmt.Errorf("could not get UUID: %w", err) - } - slog.InfoContext(ctx, "generated UUID", "uuid", generatedUUID) - srv := dependencyContainer.SetupHTTPServer() + srv.POST("/v1/register", dependencyContainer.SetupRegisterHandler()) + if err := srv.Serve(ctx); err != nil { return fmt.Errorf("could not start HTTP server: %w", err) } diff --git a/apps/srv/migrations/002_create_users_table.sql b/apps/srv/migrations/002_create_users_table.sql new file mode 100644 index 0000000..919ca34 --- /dev/null +++ b/apps/srv/migrations/002_create_users_table.sql @@ -0,0 +1,22 @@ +-- Write your migrate up statements here + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY NOT NULL DEFAULT pg_catalog.gen_random_uuid(), + username TEXT NOT NULL, + email TEXT NOT NULL, + password TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() at time zone 'UTC'), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() at time zone 'UTC'), + deleted_at TIMESTAMP WITH TIME ZONE NULL +); + +-- uq username when not empty +CREATE UNIQUE INDEX IF NOT EXISTS users_username_unique ON users (username) WHERE username != ''; +CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users (email); + +---- create above / drop below ---- + +DROP TABLE IF EXISTS users; + +-- Write your migrate down statements here. If this migration is irreversible +-- Then delete the separator line above.