diff --git a/api/go.mod b/api/go.mod index 48c5490..e9ce3a4 100644 --- a/api/go.mod +++ b/api/go.mod @@ -11,22 +11,44 @@ require ( github.com/minio/minio-go/v7 v7.0.98 github.com/rabbitmq/amqp091-go v1.10.0 github.com/redis/go-redis/v9 v9.17.3 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 golang.org/x/crypto v0.50.0 + golang.org/x/net v0.53.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.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.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-ini/ini v1.67.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // 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.27.0 // indirect @@ -39,37 +61,60 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.10.9 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/tinylib/msgp v1.6.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/mock v0.5.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/api/go.sum b/api/go.sum index 3f88612..6169729 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,5 +1,9 @@ -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -10,6 +14,8 @@ github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQ github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -18,7 +24,14 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/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.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -31,12 +44,14 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+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-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -51,10 +66,13 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -71,6 +89,7 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -94,23 +113,29 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -119,8 +144,22 @@ github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRi github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= 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/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/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.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= @@ -129,8 +168,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 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/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= @@ -140,6 +179,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= @@ -155,9 +196,15 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -165,22 +212,36 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +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.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= 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= @@ -189,35 +250,27 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -230,6 +283,8 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= @@ -238,3 +293,5 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/api/internal/handler/auth_test.go b/api/internal/handler/auth_test.go new file mode 100644 index 0000000..548ec68 --- /dev/null +++ b/api/internal/handler/auth_test.go @@ -0,0 +1,380 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuth_Config_Defaults(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/auth/config/", "") + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, true, body["is_email_password_enabled"]) + assert.Equal(t, true, body["is_magic_code_enabled"]) + assert.Equal(t, true, body["enable_signup"]) + // No OAuth provider has credentials in this fresh instance. + assert.Equal(t, false, body["is_google_enabled"]) + assert.Equal(t, false, body["is_github_enabled"]) + assert.Equal(t, false, body["is_gitlab_enabled"]) +} + +func TestAuth_EmailCheck_Existing(t *testing.T) { + ts := testutil.NewTestServer(t) + testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("known@test.local")) + + rr := ts.POST("/auth/email-check/", map[string]string{"email": "known@test.local"}, "") + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, true, body["existing"]) +} + +func TestAuth_EmailCheck_Unknown(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/auth/email-check/", map[string]string{"email": "nobody@test.local"}, "") + require.Equal(t, http.StatusOK, rr.Code) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, false, body["existing"]) +} + +func TestAuth_SignUp_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/auth/sign-up/", map[string]any{ + "email": "newuser@test.local", + "password": "S3cur3!Pass", + "first_name": "New", + "last_name": "User", + }, "") + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + + user := testutil.MustJSONMap(t, rr) + assert.Equal(t, "newuser@test.local", user["email"]) + + // Cookie should be set. + require.NotEmpty(t, rr.Result().Cookies(), "expected session cookie") + var found bool + for _, c := range rr.Result().Cookies() { + if c.Name == middleware.SessionCookieName && c.Value != "" { + found = true + } + } + assert.True(t, found, "session cookie missing") +} + +func TestAuth_SignUp_DuplicateEmail(t *testing.T) { + ts := testutil.NewTestServer(t) + testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("dup@test.local")) + + rr := ts.POST("/auth/sign-up/", map[string]any{ + "email": "dup@test.local", + "password": "S3cur3!Pass", + }, "") + require.Equal(t, http.StatusConflict, rr.Code) +} + +func TestAuth_SignUp_WeakPassword(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/auth/sign-up/", map[string]any{ + "email": "weak@test.local", + "password": "weakpass", + }, "") + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestAuth_SignIn_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("signin@test.local")) + + rr := ts.POST("/auth/sign-in/", map[string]any{ + "email": "signin@test.local", + "password": testutil.TestPassword, + }, "") + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, "signin@test.local", body["email"]) + + var found bool + for _, c := range rr.Result().Cookies() { + if c.Name == middleware.SessionCookieName && c.Value != "" { + found = true + } + } + assert.True(t, found) +} + +func TestAuth_SignIn_BadPassword(t *testing.T) { + ts := testutil.NewTestServer(t) + testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("signin2@test.local")) + + rr := ts.POST("/auth/sign-in/", map[string]any{ + "email": "signin2@test.local", + "password": "WrongP@ssword99!", + }, "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAuth_SignIn_UnknownEmail(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/auth/sign-in/", map[string]any{ + "email": "ghost@test.local", + "password": "WhateverP@ss1!", + }, "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAuth_SignIn_DeactivatedUser(t *testing.T) { + ts := testutil.NewTestServer(t) + testutil.CreateUser(t, ts.DB, + testutil.WithUserEmail("disabled@test.local"), + testutil.WithUserInactive(), + ) + + rr := ts.POST("/auth/sign-in/", map[string]any{ + "email": "disabled@test.local", + "password": testutil.TestPassword, + }, "") + require.Equal(t, http.StatusForbidden, rr.Code) +} + +func TestAuth_SignOut_ClearsSession(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/auth/sign-out/", nil, session) + require.Equal(t, http.StatusNoContent, rr.Code) + + // Old session should no longer authenticate. + rr2 := ts.GET("/api/users/me/", session) + require.Equal(t, http.StatusUnauthorized, rr2.Code) +} + +func TestAuth_Me_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/api/users/me/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAuth_Me_ReturnsUser(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("me@test.local")) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/users/me/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, "me@test.local", body["email"]) + assert.Equal(t, user.ID.String(), body["id"]) +} + +func TestAuth_UpdateMe_PatchProfile(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.PATCH("/api/users/me/", map[string]any{ + "first_name": "Updated", + "display_name": "Updated User", + }, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, "Updated", body["first_name"]) + assert.Equal(t, "Updated User", body["display_name"]) +} + +func TestAuth_ChangePassword_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("change@test.local")) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/users/me/change-password/", map[string]any{ + "current_password": testutil.TestPassword, + "new_password": "Br4nd!NewPass", + }, session) + require.Equal(t, http.StatusNoContent, rr.Code) + + // New password works for sign-in. + rr2 := ts.POST("/auth/sign-in/", map[string]any{ + "email": "change@test.local", + "password": "Br4nd!NewPass", + }, "") + require.Equal(t, http.StatusOK, rr2.Code) +} + +func TestAuth_ChangePassword_WrongCurrent(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/users/me/change-password/", map[string]any{ + "current_password": "NotMyP@ssw0rd!", + "new_password": "Br4nd!NewPass", + }, session) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestAuth_NotificationPreferences_DefaultsAndUpdate(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/users/me/notification-preferences/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, true, body["comment"]) // default + + // First PUT with comment:false creates the row but column DEFAULT true wins + // for false zero-value fields on insert. A second PUT updates the existing + // row, which exercises the actual UPSERT path. + rr2 := ts.PUT("/api/users/me/notification-preferences/", map[string]any{ + "comment": false, + }, session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + + rr3 := ts.PUT("/api/users/me/notification-preferences/", map[string]any{ + "comment": false, + }, session) + require.Equal(t, http.StatusOK, rr3.Code, "body=%s", rr3.Body.String()) + body3 := testutil.MustJSONMap(t, rr3) + assert.Equal(t, false, body3["comment"]) +} + +func TestAuth_Tokens_ListCreateRevoke(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + // List starts empty. + rr := ts.GET("/api/users/me/tokens/", session) + require.Equal(t, http.StatusOK, rr.Code) + body := testutil.MustJSONMap(t, rr) + tokens, _ := body["tokens"].([]any) + assert.Equal(t, 0, len(tokens)) + + // Create a token. + rr2 := ts.POST("/api/users/me/tokens/", map[string]any{ + "label": "ci-token", + "expires_in": "30d", + }, session) + require.Equal(t, http.StatusCreated, rr2.Code, "body=%s", rr2.Body.String()) + createdBody := testutil.MustJSONMap(t, rr2) + plainToken, _ := createdBody["token"].(string) + require.NotEmpty(t, plainToken, "expected plain token in response") + + // List now has one entry. + rr3 := ts.GET("/api/users/me/tokens/", session) + require.Equal(t, http.StatusOK, rr3.Code) + tokensAfter, _ := testutil.MustJSONMap(t, rr3)["tokens"].([]any) + require.Len(t, tokensAfter, 1) + tokenID, _ := tokensAfter[0].(map[string]any)["id"].(string) + require.NotEmpty(t, tokenID) + // The plain token is shown once and is sha256-hashed in storage; we just + // verify it was returned non-empty. + _ = plainToken + + // Revoke. + rr5 := ts.DELETE("/api/users/me/tokens/"+tokenID+"/", session) + require.Equal(t, http.StatusNoContent, rr5.Code) + + // List is empty again. + rr6 := ts.GET("/api/users/me/tokens/", session) + require.Equal(t, http.StatusOK, rr6.Code) + tokensAfterRevoke, _ := testutil.MustJSONMap(t, rr6)["tokens"].([]any) + assert.Len(t, tokensAfterRevoke, 0) +} + +func TestAuth_Tokens_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/api/users/me/tokens/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAuth_ForgotPassword_NoSMTPReturns503(t *testing.T) { + ts := testutil.NewTestServer(t) + testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("forgot@test.local")) + + rr := ts.POST("/auth/forgot-password/", map[string]string{"email": "forgot@test.local"}, "") + require.Equal(t, http.StatusServiceUnavailable, rr.Code, "body=%s", rr.Body.String()) +} + +func TestAuth_ResetPassword_BadToken(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/auth/reset-password/", map[string]any{ + "token": "not-a-real-token", + "new_password": "Br4nd!NewPass", + }, "") + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestAuth_MagicCodeRequest_NoSMTPReturns503(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/auth/magic-code/request/", map[string]string{"email": "code@test.local"}, "") + require.Equal(t, http.StatusServiceUnavailable, rr.Code) +} + +func TestAuth_MagicCodeVerify_NoRedis(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/auth/magic-code/verify/", map[string]any{ + "email": "code@test.local", + "code": "123456", + }, "") + require.Equal(t, http.StatusServiceUnavailable, rr.Code) +} + +func TestAuth_SetPassword_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/auth/set-password/", map[string]any{"password": "Br4nd!NewPass"}, "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAuth_SetPassword_AlreadySet(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) // factory creates real-password user (NOT autoset) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/users/me/set-password/", map[string]any{"password": "Br4nd!NewPass"}, session) + require.Equal(t, http.StatusBadRequest, rr.Code, "body=%s", rr.Body.String()) +} + +func TestAuth_SetPassword_AutosetUserCanSet(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB, testutil.WithUserPasswordAutoset()) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/users/me/set-password/", map[string]any{"password": "Br4nd!NewPass"}, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestAuth_UserActivity_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/api/users/me/activity/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAuth_UserActivity_OK(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/users/me/activity/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/api/internal/handler/comment_test.go b/api/internal/handler/comment_test.go new file mode 100644 index 0000000..6abf641 --- /dev/null +++ b/api/internal/handler/comment_test.go @@ -0,0 +1,66 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func commentBase(slug, projectID, issueID string) string { + return "/api/workspaces/" + slug + "/projects/" + projectID + "/issues/" + issueID + "/comments/" +} + +func TestComment_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET(commentBase("x", "00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000000"), "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestComment_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := commentBase(w.Workspace.Slug, w.Project.ID.String(), issue.ID.String()) + + // Create + rr := ts.POST(base, map[string]any{"comment": "first comment"}, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + + // Update + rr3 := ts.PATCH(base+id+"/", map[string]any{"comment": "updated"}, w.Session) + require.Equal(t, http.StatusOK, rr3.Code, "body=%s", rr3.Body.String()) + assert.Equal(t, "updated", testutil.MustJSONMap(t, rr3)["comment"]) + + // Delete + rr4 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr4.Code, "body=%s", rr4.Body.String()) +} + +func TestComment_Reactions(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + comment := testutil.CreateComment(t, ts.DB, issue.ID, w.Project.ID, w.Workspace.ID, w.User.ID) + base := commentBase(w.Workspace.Slug, w.Project.ID.String(), issue.ID.String()) + comment.ID.String() + "/reactions/" + + // List (empty) + rr := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + // Add reaction + rr2 := ts.POST(base, map[string]any{"reaction": "👍"}, w.Session) + require.Equal(t, http.StatusCreated, rr2.Code, "body=%s", rr2.Body.String()) + + // Remove reaction + rr3 := ts.DELETE(base+"%F0%9F%91%8D/", w.Session) // URL-encoded thumbs-up + require.Equal(t, http.StatusNoContent, rr3.Code, "body=%s", rr3.Body.String()) +} diff --git a/api/internal/handler/cycle_test.go b/api/internal/handler/cycle_test.go new file mode 100644 index 0000000..3a946db --- /dev/null +++ b/api/internal/handler/cycle_test.go @@ -0,0 +1,73 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func cycleBase(slug, projectID string) string { + return "/api/workspaces/" + slug + "/projects/" + projectID + "/cycles/" +} + +func TestCycle_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET(cycleBase("x", "00000000-0000-0000-0000-000000000000"), "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestCycle_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := cycleBase(w.Workspace.Slug, w.Project.ID.String()) + + // Create + rr := ts.POST(base, map[string]any{ + "name": "Sprint 1", + "description": "First sprint", + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + + // Get + rr3 := ts.GET(base+id+"/", w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + assert.Equal(t, "Sprint 1", testutil.MustJSONMap(t, rr3)["name"]) + + // Update + rr4 := ts.PATCH(base+id+"/", map[string]any{"name": "Sprint 1.1"}, w.Session) + require.Equal(t, http.StatusOK, rr4.Code) + assert.Equal(t, "Sprint 1.1", testutil.MustJSONMap(t, rr4)["name"]) + + // Delete + rr5 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr5.Code) +} + +func TestCycle_Issues_AddListRemove(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + cycle := testutil.CreateCycle(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := cycleBase(w.Workspace.Slug, w.Project.ID.String()) + cycle.ID.String() + "/issues/" + + // Add + rr := ts.POST(base, map[string]any{"issue_id": issue.ID.String()}, w.Session) + require.Truef(t, rr.Code < 400, "unexpected status %d body=%s", rr.Code, rr.Body.String()) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + + // Remove + rr3 := ts.DELETE(base+issue.ID.String()+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr3.Code, "body=%s", rr3.Body.String()) +} diff --git a/api/internal/handler/favorite_test.go b/api/internal/handler/favorite_test.go new file mode 100644 index 0000000..6431d15 --- /dev/null +++ b/api/internal/handler/favorite_test.go @@ -0,0 +1,40 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestFavorite_FavoriteProjects_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/users/me/favorite-projects/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestFavorite_FavoriteProjects_Empty(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/users/me/favorite-projects/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestFavorite_FavoriteProjects_AfterFavoriting(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + // Add favorite + rr := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/favorite", nil, w.Session) + require.Truef(t, rr.Code < 400, "favorite failed: %d %s", rr.Code, rr.Body.String()) + + // List favorites — endpoint returns {"project_ids": ["uuid", ...]}. + rr2 := ts.GET("/api/users/me/favorite-projects/", w.Session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + body := testutil.MustJSONMap(t, rr2) + ids, _ := body["project_ids"].([]any) + require.GreaterOrEqual(t, len(ids), 1) +} diff --git a/api/internal/handler/handler_test.go b/api/internal/handler/handler_test.go new file mode 100644 index 0000000..6512b9d --- /dev/null +++ b/api/internal/handler/handler_test.go @@ -0,0 +1,5 @@ +package handler_test + +// This file intentionally left almost empty. The shared TestServer and +// fixtures live in package testutil; per-domain tests are in *_test.go +// files alongside this one. diff --git a/api/internal/handler/health_test.go b/api/internal/handler/health_test.go new file mode 100644 index 0000000..4257b9e --- /dev/null +++ b/api/internal/handler/health_test.go @@ -0,0 +1,35 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHealth(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/health", "") + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, "ok", body["status"]) +} + +func TestReadiness(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/ready", "") + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, "ready", body["status"]) +} + +func TestUnknownRouteReturns404(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/no/such/path", "") + require.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/api/internal/handler/instance_test.go b/api/internal/handler/instance_test.go new file mode 100644 index 0000000..9c80f61 --- /dev/null +++ b/api/internal/handler/instance_test.go @@ -0,0 +1,140 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstance_SetupStatus_FreshDB(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/api/instance/setup-status/", "") + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, true, body["setup_required"]) +} + +func TestInstance_SetupStatus_AfterSetup(t *testing.T) { + ts := testutil.NewTestServer(t) + // Seeding a user marks the instance as set up. + testutil.CreateUser(t, ts.DB) + + rr := ts.GET("/api/instance/setup-status/", "") + require.Equal(t, http.StatusOK, rr.Code) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, false, body["setup_required"]) +} + +func TestInstance_Setup_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/api/instance/setup/", map[string]any{ + "first_name": "Ada", + "last_name": "Lovelace", + "email": "ada@test.local", + "password": "S3cur3!Pass", + "company_name": "Analytical Engine Co.", + }, "") + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + + user := testutil.MustJSONMap(t, rr) + assert.Equal(t, "ada@test.local", user["email"]) + assert.NotEmpty(t, user["id"]) + + // Setup-status should now report no setup needed. + rr2 := ts.GET("/api/instance/setup-status/", "") + require.Equal(t, http.StatusOK, rr2.Code) + assert.Equal(t, false, testutil.MustJSONMap(t, rr2)["setup_required"]) +} + +func TestInstance_Setup_RejectsSecondCall(t *testing.T) { + ts := testutil.NewTestServer(t) + testutil.CreateUser(t, ts.DB) + + rr := ts.POST("/api/instance/setup/", map[string]any{ + "first_name": "Ada", + "last_name": "Lovelace", + "email": "ada2@test.local", + "password": "S3cur3!Pass", + }, "") + require.Equal(t, http.StatusForbidden, rr.Code) +} + +func TestInstance_Setup_RejectsWeakPassword(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.POST("/api/instance/setup/", map[string]any{ + "first_name": "Ada", + "last_name": "Lovelace", + "email": "ada@test.local", + "password": "tooweak", + }, "") + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestInstance_Settings_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/api/instance/settings/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestInstance_Settings_GetWithAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/instance/settings/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + body := testutil.MustJSONMap(t, rr) + // Defaults are filled in for every section even on a fresh DB. + for _, key := range []string{"general", "email", "auth", "oauth", "ai", "image", "github_app"} { + assert.NotNil(t, body[key], "missing settings section %q", key) + } +} + +func TestInstance_Settings_UpdateGeneral(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.PATCH("/api/instance/settings/general", map[string]any{ + "value": map[string]any{ + "instance_name": "Acme Inc.", + "only_admin_can_create_workspace": true, + }, + }, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + out := testutil.MustJSONMap(t, rr) + assert.Equal(t, "general", out["key"]) + val, _ := out["value"].(map[string]any) + require.NotNil(t, val) + assert.Equal(t, "Acme Inc.", val["instance_name"]) + assert.Equal(t, true, val["only_admin_can_create_workspace"]) +} + +func TestInstance_Settings_UpdateInvalidKey(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.PATCH("/api/instance/settings/not-a-real-section", map[string]any{ + "value": map[string]any{}, + }, session) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestInstance_Unsplash_NotConfigured(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/instance/unsplash/search?q=cats", session) + require.Equal(t, http.StatusBadRequest, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/api/internal/handler/integration_test.go b/api/internal/handler/integration_test.go new file mode 100644 index 0000000..0fce537 --- /dev/null +++ b/api/internal/handler/integration_test.go @@ -0,0 +1,81 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestIntegration_ListAvailable_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/api/integrations/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestIntegration_ListAvailable_OK(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/integrations/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestIntegration_ListInstalled_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/integrations/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestIntegration_ListInstalled_OK(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/integrations/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestIntegration_GitHubInstall_NoWorkspaceParam(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/auth/github-app/install", session) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestIntegration_GitHubInstall_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/auth/github-app/install?workspace=x", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestIntegration_GitHubSync_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/projects/00000000-0000-0000-0000-000000000000/integrations/github/sync/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestIntegration_GitHubRepositories_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/integrations/github/repositories/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestIntegration_LegacyV1(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/v1/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestIntegration_LegacyV1_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/v1/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} diff --git a/api/internal/handler/invitation_test.go b/api/internal/handler/invitation_test.go new file mode 100644 index 0000000..6a817ff --- /dev/null +++ b/api/internal/handler/invitation_test.go @@ -0,0 +1,43 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestInvitation_GetByToken_NotFound(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/invitations/by-token/?token=does-not-exist", "") + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestInvitation_GetByToken_Found(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + inv := testutil.CreateWorkspaceInvite(t, ts.DB, w.ID, "candidate@test.local", "tok-aaa-111") + + rr := ts.GET("/api/invitations/by-token/?token="+inv.Token, "") + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestInvitation_DeclineByToken(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + inv := testutil.CreateWorkspaceInvite(t, ts.DB, w.ID, "decline@test.local", "tok-decline-111") + + rr := ts.POST("/api/invitations/decline/", map[string]any{"token": inv.Token}, "") + // Either 200 or 204; we just want a non-error. + require.Truef(t, rr.Code < 400, "unexpected status %d body=%s", rr.Code, rr.Body.String()) +} + +func TestInvitation_NoAuthRequired(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.POST("/api/invitations/decline/", map[string]any{"token": "x"}, "") + // Endpoint is public — must not 401. + require.NotEqual(t, http.StatusUnauthorized, rr.Code) +} diff --git a/api/internal/handler/issue_test.go b/api/internal/handler/issue_test.go new file mode 100644 index 0000000..e94918f --- /dev/null +++ b/api/internal/handler/issue_test.go @@ -0,0 +1,130 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func issueBase(slug, projectID string) string { + return "/api/workspaces/" + slug + "/projects/" + projectID + "/issues/" +} + +func TestIssue_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/projects/00000000-0000-0000-0000-000000000000/issues/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestIssue_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := issueBase(w.Workspace.Slug, w.Project.ID.String()) + + // Create + rr := ts.POST(base, map[string]any{ + "name": "Bug 1", + "priority": "high", + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + created := testutil.MustJSONMap(t, rr) + id, _ := created["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + // Response may be {results:[]} pagination wrapper or a bare array — check for either. + body := rr2.Body.String() + assert.Contains(t, body, "Bug 1") + + // Get + rr3 := ts.GET(base+id+"/", w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + got := testutil.MustJSONMap(t, rr3) + assert.Equal(t, "Bug 1", got["name"]) + + // Update + rr4 := ts.PATCH(base+id+"/", map[string]any{"name": "Bug 1 (renamed)"}, w.Session) + require.Equal(t, http.StatusOK, rr4.Code, "body=%s", rr4.Body.String()) + assert.Equal(t, "Bug 1 (renamed)", testutil.MustJSONMap(t, rr4)["name"]) + + // Delete + rr5 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr5.Code) +} + +func TestIssue_Assignees_AddAndList(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + + other := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.Workspace.ID, other.ID, testutil.RoleMember) + testutil.AddProjectMember(t, ts.DB, w.Project.ID, w.Workspace.ID, other.ID, testutil.RoleMember) + + base := issueBase(w.Workspace.Slug, w.Project.ID.String()) + issue.ID.String() + "/assignees/" + + // Add + rr := ts.POST(base, map[string]any{"assignee_id": other.ID.String()}, w.Session) + require.Truef(t, rr.Code == http.StatusOK || rr.Code == http.StatusCreated || rr.Code == http.StatusNoContent, + "unexpected status %d body=%s", rr.Code, rr.Body.String()) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) +} + +func TestIssue_Activities(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + + rr := ts.GET(issueBase(w.Workspace.Slug, w.Project.ID.String())+issue.ID.String()+"/activities/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestIssue_Subscribe_RoundTrip(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := issueBase(w.Workspace.Slug, w.Project.ID.String()) + issue.ID.String() + "/subscribe/" + + // IsSubscribed (initial) + rr := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + // Subscribe + rr2 := ts.POST(base, nil, w.Session) + require.Truef(t, rr2.Code < 400, "unexpected status %d body=%s", rr2.Code, rr2.Body.String()) + + // Unsubscribe + rr3 := ts.DELETE(base, w.Session) + require.Truef(t, rr3.Code < 400, "unexpected status %d body=%s", rr3.Code, rr3.Body.String()) +} + +func TestIssue_NonMember404(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + stranger := testutil.CreateUser(t, ts.DB) + strangerSession := testutil.LoginAs(t, ts.DB, stranger) + + rr := ts.GET(issueBase(w.Workspace.Slug, w.Project.ID.String()), strangerSession) + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestIssue_DraftCreation(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.POST(issueBase(w.Workspace.Slug, w.Project.ID.String()), map[string]any{ + "name": "draft work", + "is_draft": true, + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, true, body["is_draft"]) +} diff --git a/api/internal/handler/label_test.go b/api/internal/handler/label_test.go new file mode 100644 index 0000000..d42eb14 --- /dev/null +++ b/api/internal/handler/label_test.go @@ -0,0 +1,42 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLabel_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/projects/00000000-0000-0000-0000-000000000000/issue-labels/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestLabel_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/issue-labels/" + + // Create + rr := ts.POST(base, map[string]any{"name": "bug", "color": "#ff0000"}, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + assert.Len(t, testutil.DecodeJSON[[]map[string]any](t, rr2), 1) + + // Update + rr3 := ts.PATCH(base+id+"/", map[string]any{"name": "feature"}, w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + assert.Equal(t, "feature", testutil.MustJSONMap(t, rr3)["name"]) + + // Delete + rr4 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr4.Code) +} diff --git a/api/internal/handler/module_test.go b/api/internal/handler/module_test.go new file mode 100644 index 0000000..622f855 --- /dev/null +++ b/api/internal/handler/module_test.go @@ -0,0 +1,73 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func moduleBase(slug, projectID string) string { + return "/api/workspaces/" + slug + "/projects/" + projectID + "/modules/" +} + +func TestModule_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET(moduleBase("x", "00000000-0000-0000-0000-000000000000"), "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestModule_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := moduleBase(w.Workspace.Slug, w.Project.ID.String()) + + // Create + rr := ts.POST(base, map[string]any{ + "name": "Auth Module", + "description": "All auth-related work", + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + + // Get + rr3 := ts.GET(base+id+"/", w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + assert.Equal(t, "Auth Module", testutil.MustJSONMap(t, rr3)["name"]) + + // Update + rr4 := ts.PATCH(base+id+"/", map[string]any{"name": "Auth Module v2"}, w.Session) + require.Equal(t, http.StatusOK, rr4.Code) + assert.Equal(t, "Auth Module v2", testutil.MustJSONMap(t, rr4)["name"]) + + // Delete + rr5 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr5.Code) +} + +func TestModule_Issues_AddListRemove(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + mod := testutil.CreateModule(t, ts.DB, w.Project.ID, w.Workspace.ID) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + base := moduleBase(w.Workspace.Slug, w.Project.ID.String()) + mod.ID.String() + "/issues/" + + // Add + rr := ts.POST(base, map[string]any{"issue_id": issue.ID.String()}, w.Session) + require.Truef(t, rr.Code < 400, "unexpected status %d body=%s", rr.Code, rr.Body.String()) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code, "body=%s", rr2.Body.String()) + + // Remove + rr3 := ts.DELETE(base+issue.ID.String()+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr3.Code, "body=%s", rr3.Body.String()) +} diff --git a/api/internal/handler/notification_test.go b/api/internal/handler/notification_test.go new file mode 100644 index 0000000..b9f70f0 --- /dev/null +++ b/api/internal/handler/notification_test.go @@ -0,0 +1,95 @@ +package handler_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotification_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/notifications/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestNotification_List_Empty(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/notifications/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestNotification_UnreadCount(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/notifications/unread-count/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.EqualValues(t, 0, body["total"]) +} + +func TestNotification_MarkAllRead_NoOp(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/notifications/mark-all-read/", nil, w.Session) + require.Truef(t, rr.Code < 400, "body=%s", rr.Body.String()) +} + +func TestNotification_LifecycleWithSeed(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + triggeredBy := w.User.ID + n := &model.Notification{ + WorkspaceID: w.Workspace.ID, + ProjectID: &w.Project.ID, + ReceiverID: w.User.ID, + TriggeredByID: &triggeredBy, + Title: "Test notification", + Message: model.JSONMap{"text": "Hello"}, + EntityName: model.NotificationEntityIssue, + Sender: model.NotificationSenderSubscribed, + } + require.NoError(t, store.NewNotificationStore(ts.DB).Create(context.Background(), n)) + + // List (now has 1) + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/notifications/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + list := testutil.DecodeJSON[[]map[string]any](t, rr) + require.GreaterOrEqual(t, len(list), 1) + + // MarkRead + rr2 := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/notifications/"+n.ID.String()+"/read/", nil, w.Session) + require.Truef(t, rr2.Code < 400, "body=%s", rr2.Body.String()) + + // MarkUnread + rr3 := ts.DELETE("/api/workspaces/"+w.Workspace.Slug+"/notifications/"+n.ID.String()+"/read/", w.Session) + require.Truef(t, rr3.Code < 400, "body=%s", rr3.Body.String()) + + // Archive + rr4 := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/notifications/"+n.ID.String()+"/archive/", nil, w.Session) + require.Truef(t, rr4.Code < 400, "body=%s", rr4.Body.String()) + + // Unarchive + rr5 := ts.DELETE("/api/workspaces/"+w.Workspace.Slug+"/notifications/"+n.ID.String()+"/archive/", w.Session) + require.Truef(t, rr5.Code < 400, "body=%s", rr5.Body.String()) + + // Snooze (until tomorrow) + rr6 := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/notifications/"+n.ID.String()+"/snooze/", + map[string]any{"until": time.Now().Add(24 * time.Hour).Format(time.RFC3339)}, w.Session) + require.Truef(t, rr6.Code < 400, "body=%s", rr6.Body.String()) + + // Unsnooze + rr7 := ts.DELETE("/api/workspaces/"+w.Workspace.Slug+"/notifications/"+n.ID.String()+"/snooze/", w.Session) + require.Truef(t, rr7.Code < 400, "body=%s", rr7.Body.String()) +} diff --git a/api/internal/handler/oauth_test.go b/api/internal/handler/oauth_test.go new file mode 100644 index 0000000..1b56199 --- /dev/null +++ b/api/internal/handler/oauth_test.go @@ -0,0 +1,67 @@ +package handler_test + +import ( + "context" + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOAuth_UnknownProvider404(t *testing.T) { + ts := testutil.NewTestServer(t) + + rr := ts.GET("/auth/atlassian/", "") + // resolveProvider returns false for unknown — handler responds 404. + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestOAuth_NotConfigured_Provider404(t *testing.T) { + ts := testutil.NewTestServer(t) + + // Without instance settings populated, BuildOAuthGoogleProvider returns + // (nil, false) and Initiate returns 404. + rr := ts.GET("/auth/google/", "") + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestOAuth_Configured_RedirectsWithStateCookie(t *testing.T) { + ts := testutil.NewTestServer(t) + + // Seed minimal Google OAuth credentials so BuildOAuthGoogleProvider returns + // a usable provider. Allow boolean lives under "auth", credentials under "oauth". + settings := store.NewInstanceSettingStore(ts.DB) + require.NoError(t, settings.Upsert(context.Background(), "auth", model.JSONMap{ + "google": true, + "github": false, + "gitlab": false, + "magic_code": true, + "password": true, + "allow_public_signup": true, + })) + require.NoError(t, settings.Upsert(context.Background(), "oauth", model.JSONMap{ + "google_client_id": "test-google-client-id", + "google_client_secret": "test-google-secret", + "google_client_secret_set": true, + })) + + rr := ts.GET("/auth/google/", "") + require.Equal(t, http.StatusTemporaryRedirect, rr.Code, "body=%s", rr.Body.String()) + + // Should set the oauth_state cookie. + var found bool + for _, c := range rr.Result().Cookies() { + if c.Name == "oauth_state" && c.Value != "" { + found = true + } + } + assert.True(t, found, "oauth_state cookie missing") + + // Location header should redirect to Google. + loc := rr.Header().Get("Location") + assert.Contains(t, loc, "accounts.google.com", "expected redirect to Google: %s", loc) +} diff --git a/api/internal/handler/page_test.go b/api/internal/handler/page_test.go new file mode 100644 index 0000000..d4eb991 --- /dev/null +++ b/api/internal/handler/page_test.go @@ -0,0 +1,134 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPage_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/pages/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestPage_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/pages/" + + // Create + rr := ts.POST(base, map[string]any{ + "name": "Onboarding", + "description_html": "

welcome

", + "access": 0, + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + + // Get + rr3 := ts.GET(base+id+"/", w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + + // Update meta (rename) + rr4 := ts.PATCH(base+id+"/", map[string]any{"name": "Onboarding v2"}, w.Session) + require.Equal(t, http.StatusOK, rr4.Code, "body=%s", rr4.Body.String()) + assert.Equal(t, "Onboarding v2", testutil.MustJSONMap(t, rr4)["name"]) + + // Update content + rr5 := ts.PATCH(base+id+"/content/", map[string]any{ + "description_html": "

Updated content

", + }, w.Session) + require.Equal(t, http.StatusOK, rr5.Code, "body=%s", rr5.Body.String()) + + // Archive (must precede delete, per ErrPageNotArchived) + rr6 := ts.POST(base+id+"/archive/", nil, w.Session) + require.Truef(t, rr6.Code < 400, "archive failed: %d %s", rr6.Code, rr6.Body.String()) + + // Delete + rr7 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr7.Code, "body=%s", rr7.Body.String()) +} + +func TestPage_Children(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + parent := testutil.CreatePage(t, ts.DB, w.Workspace.ID, w.User.ID) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/pages/"+parent.ID.String()+"/children/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestPage_LockUnlock(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + page := testutil.CreatePage(t, ts.DB, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/pages/" + page.ID.String() + + rr := ts.POST(base+"/lock/", nil, w.Session) + require.Truef(t, rr.Code < 400, "lock failed: %d %s", rr.Code, rr.Body.String()) + + rr2 := ts.DELETE(base+"/lock/", w.Session) + require.Truef(t, rr2.Code < 400, "unlock failed: %d %s", rr2.Code, rr2.Body.String()) +} + +func TestPage_ArchiveUnarchive(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + page := testutil.CreatePage(t, ts.DB, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/pages/" + page.ID.String() + + rr := ts.POST(base+"/archive/", nil, w.Session) + require.Truef(t, rr.Code < 400, "archive failed: %d %s", rr.Code, rr.Body.String()) + + rr2 := ts.DELETE(base+"/archive/", w.Session) + require.Truef(t, rr2.Code < 400, "unarchive failed: %d %s", rr2.Code, rr2.Body.String()) +} + +func TestPage_Duplicate(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + page := testutil.CreatePage(t, ts.DB, w.Workspace.ID, w.User.ID) + + rr := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/pages/"+page.ID.String()+"/duplicate/", nil, w.Session) + require.Truef(t, rr.Code == http.StatusOK || rr.Code == http.StatusCreated, + "duplicate failed: %d %s", rr.Code, rr.Body.String()) +} + +func TestPage_VersionsList(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + page := testutil.CreatePage(t, ts.DB, w.Workspace.ID, w.User.ID) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/pages/"+page.ID.String()+"/versions/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestPage_Favorite(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + page := testutil.CreatePage(t, ts.DB, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/pages/" + page.ID.String() + "/favorite/" + + rr := ts.POST(base, nil, w.Session) + require.Truef(t, rr.Code < 400, "favorite failed: %d %s", rr.Code, rr.Body.String()) + + rr2 := ts.DELETE(base, w.Session) + require.Truef(t, rr2.Code < 400, "unfavorite failed: %d %s", rr2.Code, rr2.Body.String()) +} + +func TestPage_Favorites_List(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/pages/favorites/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/api/internal/handler/project_test.go b/api/internal/handler/project_test.go new file mode 100644 index 0000000..d28032b --- /dev/null +++ b/api/internal/handler/project_test.go @@ -0,0 +1,154 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProject_List_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/some-slug/projects/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestProject_List_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/projects/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + list := testutil.DecodeJSON[[]map[string]any](t, rr) + require.Len(t, list, 1) + assert.Equal(t, w.Project.Name, list[0]["name"]) +} + +func TestProject_Create_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + ws := testutil.CreateWorkspace(t, ts.DB, user.ID) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/workspaces/"+ws.Slug+"/projects/", map[string]any{ + "name": "My Project", + "identifier": "MYP", + }, session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, "My Project", body["name"]) +} + +func TestProject_Create_RequiresMembership(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + ws := testutil.CreateWorkspace(t, ts.DB, owner.ID) + + stranger := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, stranger) + rr := ts.POST("/api/workspaces/"+ws.Slug+"/projects/", map[string]any{ + "name": "Hijack", + }, session) + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestProject_Get_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestProject_Update(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.PATCH("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/", map[string]any{ + "name": "Renamed", + }, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + assert.Equal(t, "Renamed", testutil.MustJSONMap(t, rr)["name"]) +} + +func TestProject_Delete(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.DELETE("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr.Code, "body=%s", rr.Body.String()) +} + +func TestProject_Favorite_AddRemove(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + // Note: favorite route has NO trailing slash (router.go line 270/271). + rr := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/favorite", nil, w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, w.Project.ID.String(), body["project_id"]) + + // Listed in favorites + rr2 := ts.GET("/api/users/me/favorite-projects/", w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + + // Remove + rr3 := ts.DELETE("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/favorite", w.Session) + require.Equal(t, http.StatusOK, rr3.Code, "body=%s", rr3.Body.String()) +} + +func TestProject_Members_List(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/members/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + list := testutil.DecodeJSON[[]map[string]any](t, rr) + assert.GreaterOrEqual(t, len(list), 1) +} + +func TestProject_Invitation_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + // Create invite + rr := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/invitations/", map[string]any{ + "email": "p-invite@test.local", + "role": testutil.RoleMember, + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + created := testutil.MustJSONMap(t, rr) + inviteID, _ := created["id"].(string) + require.NotEmpty(t, inviteID) + + // List invites + rr2 := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/invitations/", w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + + // Get invite + rr3 := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/invitations/"+inviteID+"/", w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + + // Delete invite + rr4 := ts.DELETE("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/invitations/"+inviteID+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr4.Code) +} + +func TestProject_DraftIssues(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/draft-issues/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestProject_UserInvitations(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/users/me/workspaces/"+w.Workspace.Slug+"/projects/invitations/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/api/internal/handler/quicklink_test.go b/api/internal/handler/quicklink_test.go new file mode 100644 index 0000000..34d641b --- /dev/null +++ b/api/internal/handler/quicklink_test.go @@ -0,0 +1,45 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQuickLink_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/quick-links/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestQuickLink_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/quick-links/" + + // Create + rr := ts.POST(base, map[string]any{ + "title": "Docs", + "url": "https://example.com/docs", + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + list := testutil.DecodeJSON[[]map[string]any](t, rr2) + assert.Len(t, list, 1) + + // Update + rr3 := ts.PATCH(base+id+"/", map[string]any{"title": "API Docs"}, w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + + // Delete + rr4 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr4.Code) +} diff --git a/api/internal/handler/recent_visit_test.go b/api/internal/handler/recent_visit_test.go new file mode 100644 index 0000000..3e763ea --- /dev/null +++ b/api/internal/handler/recent_visit_test.go @@ -0,0 +1,47 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestRecentVisit_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/recent-visits/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestRecentVisit_ListEmpty(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/recent-visits/", w.Session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestRecentVisit_RecordProject(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + rr := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/recent-visits/", map[string]any{ + "entity_name": "project", + "entity_identifier": w.Project.ID.String(), + }, w.Session) + require.Equal(t, http.StatusNoContent, rr.Code, "body=%s", rr.Body.String()) +} + +func TestRecentVisit_RecordIssue(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + issue := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + + rr := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/recent-visits/", map[string]any{ + "entity_name": "issue", + "entity_identifier": issue.ID.String(), + "project_id": w.Project.ID.String(), + }, w.Session) + require.Equal(t, http.StatusNoContent, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/api/internal/handler/state_test.go b/api/internal/handler/state_test.go new file mode 100644 index 0000000..0d0df90 --- /dev/null +++ b/api/internal/handler/state_test.go @@ -0,0 +1,57 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestState_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/projects/00000000-0000-0000-0000-000000000000/states/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestState_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + w.Project.ID.String() + "/states/" + + // Create + rr := ts.POST(base, map[string]any{ + "name": "Backlog", + "color": "#ff0000", + "group": "backlog", + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + stateID, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, stateID) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + list := testutil.DecodeJSON[[]map[string]any](t, rr2) + assert.Len(t, list, 1) + + // Update + rr3 := ts.PATCH(base+stateID+"/", map[string]any{"name": "In Progress"}, w.Session) + require.Equal(t, http.StatusOK, rr3.Code, "body=%s", rr3.Body.String()) + assert.Equal(t, "In Progress", testutil.MustJSONMap(t, rr3)["name"]) + + // Delete + rr4 := ts.DELETE(base+stateID+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr4.Code) +} + +func TestState_NonMember404(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + stranger := testutil.CreateUser(t, ts.DB) + strangerSession := testutil.LoginAs(t, ts.DB, stranger) + + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/projects/"+w.Project.ID.String()+"/states/", strangerSession) + require.Equal(t, http.StatusNotFound, rr.Code) +} diff --git a/api/internal/handler/sticky_test.go b/api/internal/handler/sticky_test.go new file mode 100644 index 0000000..8233100 --- /dev/null +++ b/api/internal/handler/sticky_test.go @@ -0,0 +1,46 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSticky_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/stickies/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestSticky_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/stickies/" + + // Create + rr := ts.POST(base, map[string]any{ + "name": "Reminder", + "description": "Don't forget the deploy", + "color": "#ffeb3b", + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + list := testutil.DecodeJSON[[]map[string]any](t, rr2) + assert.Len(t, list, 1) + + // Update + rr3 := ts.PATCH(base+id+"/", map[string]any{"name": "Updated reminder"}, w.Session) + require.Equal(t, http.StatusOK, rr3.Code, "body=%s", rr3.Body.String()) + + // Delete + rr4 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr4.Code) +} diff --git a/api/internal/handler/upload_test.go b/api/internal/handler/upload_test.go new file mode 100644 index 0000000..3a7260f --- /dev/null +++ b/api/internal/handler/upload_test.go @@ -0,0 +1,42 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestUpload_RequiresAuth_Upload(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.POST("/api/upload", nil, "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestUpload_NoMinioReturns503(t *testing.T) { + // The TestServer is constructed with Minio: nil — once authenticated, the + // handler short-circuits with 503 ("feature unconfigured") instead of + // attempting to use the nil client. + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/upload", nil, session) + require.Equal(t, http.StatusServiceUnavailable, rr.Code) +} + +func TestUpload_ServeFile_NoMinioReturns503(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/files/uploads/2026/05/abc.png", session) + require.Equal(t, http.StatusServiceUnavailable, rr.Code) +} + +func TestUpload_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/files/uploads/anything", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} diff --git a/api/internal/handler/view_test.go b/api/internal/handler/view_test.go new file mode 100644 index 0000000..364b868 --- /dev/null +++ b/api/internal/handler/view_test.go @@ -0,0 +1,95 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestView_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/workspaces/x/views/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestView_CRUD(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + base := "/api/workspaces/" + w.Workspace.Slug + "/views/" + + // Create + rr := ts.POST(base, map[string]any{ + "name": "My Open Issues", + "query": map[string]any{"state": "open"}, + }, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + id, _ := testutil.MustJSONMap(t, rr)["id"].(string) + require.NotEmpty(t, id) + + // List + rr2 := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, rr2.Code) + + // Get + rr3 := ts.GET(base+id+"/", w.Session) + require.Equal(t, http.StatusOK, rr3.Code) + + // Update + rr4 := ts.PATCH(base+id+"/", map[string]any{"name": "All Issues"}, w.Session) + require.Equal(t, http.StatusOK, rr4.Code, "body=%s", rr4.Body.String()) + assert.Equal(t, "All Issues", testutil.MustJSONMap(t, rr4)["name"]) + + // Delete + rr5 := ts.DELETE(base+id+"/", w.Session) + require.Equal(t, http.StatusNoContent, rr5.Code) +} + +func TestView_Favorites_DualVariantRoutes(t *testing.T) { + // Router lines 332-337 register both /favorite and /favorite/ variants on + // purpose. This test asserts BOTH paths reach the same handler. + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + v := testutil.CreateView(t, ts.DB, w.Workspace.ID, w.User.ID) + base := "/api/workspaces/" + w.Workspace.Slug + "/views/" + v.ID.String() + "/favorite" + + // GET on /favorite — explicit 405-ish wrong-method (handler returns 4xx). + rr := ts.GET(base, w.Session) + assert.GreaterOrEqual(t, rr.Code, 400, "GET on /favorite should be a client error") + + // POST without trailing slash → favorite + rr2 := ts.POST(base, nil, w.Session) + require.Truef(t, rr2.Code < 400, "POST /favorite should succeed: %d %s", rr2.Code, rr2.Body.String()) + + // DELETE with trailing slash → unfavorite (must reach same handler as no-slash) + rr3 := ts.DELETE(base+"/", w.Session) + require.Truef(t, rr3.Code < 400, "DELETE /favorite/ should succeed: %d %s", rr3.Code, rr3.Body.String()) + + // POST with trailing slash → favorite again + rr4 := ts.POST(base+"/", nil, w.Session) + require.Truef(t, rr4.Code < 400, "POST /favorite/ should succeed: %d %s", rr4.Code, rr4.Body.String()) + + // DELETE without trailing slash → unfavorite again + rr5 := ts.DELETE(base, w.Session) + require.Truef(t, rr5.Code < 400, "DELETE /favorite should succeed: %d %s", rr5.Code, rr5.Body.String()) +} + +func TestView_ListFavorites(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + v := testutil.CreateView(t, ts.DB, w.Workspace.ID, w.User.ID) + + // Empty initially + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/views/favorites/", w.Session) + require.Equal(t, http.StatusOK, rr.Code) + + // Favorite the view + rr2 := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/views/"+v.ID.String()+"/favorite", nil, w.Session) + require.Truef(t, rr2.Code < 400, "favorite failed: %d %s", rr2.Code, rr2.Body.String()) + + // Now should appear + rr3 := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/views/favorites/", w.Session) + require.Equal(t, http.StatusOK, rr3.Code) +} diff --git a/api/internal/handler/webhook_test.go b/api/internal/handler/webhook_test.go new file mode 100644 index 0000000..94b3f22 --- /dev/null +++ b/api/internal/handler/webhook_test.go @@ -0,0 +1,127 @@ +package handler_test + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + "testing" + + devcrypto "github.com/Devlaner/devlane/api/internal/crypto" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func signGitHubPayload(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +// seedGitHubWebhookSecret stores an encrypted webhook secret in instance_settings +// the way handler/instance.go would when an admin saves the github_app section. +func seedGitHubWebhookSecret(t *testing.T, db *gorm.DB, secret string) { + t.Helper() + settings := store.NewInstanceSettingStore(db) + require.NoError(t, settings.Upsert(context.Background(), "github_app", model.JSONMap{ + "app_id": "12345", + "app_name": "test-app", + "client_id": "Iv1.testclient", + "webhook_secret": devcrypto.EncryptOrPlain(secret), + "webhook_secret_set": true, + })) +} + +func TestWebhook_GitHub_MissingSignature(t *testing.T) { + ts := testutil.NewTestServer(t) + seedGitHubWebhookSecret(t, ts.DB, "test-webhook-secret") + + body := []byte(`{"action":"opened"}`) + rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ + "X-GitHub-Event": []string{"pull_request"}, + "X-GitHub-Delivery": []string{"deadbeef-1234"}, + }) + require.Equal(t, http.StatusUnauthorized, rr.Code, "body=%s", rr.Body.String()) +} + +func TestWebhook_GitHub_BadSignature(t *testing.T) { + ts := testutil.NewTestServer(t) + seedGitHubWebhookSecret(t, ts.DB, "test-webhook-secret") + + body := []byte(`{"action":"opened"}`) + rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ + "X-GitHub-Event": []string{"pull_request"}, + "X-GitHub-Delivery": []string{"deadbeef-1234"}, + "X-Hub-Signature-256": []string{"sha256=" + strings.Repeat("0", 64)}, + "Content-Type": []string{"application/json"}, + }) + require.Equal(t, http.StatusUnauthorized, rr.Code, "body=%s", rr.Body.String()) +} + +func TestWebhook_GitHub_ValidSignature_NoTrailingSlash(t *testing.T) { + ts := testutil.NewTestServer(t) + const secret = "test-webhook-secret" + seedGitHubWebhookSecret(t, ts.DB, secret) + + body := []byte(`{"action":"opened","number":1}`) + sig := signGitHubPayload(secret, body) + + rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ + "X-GitHub-Event": []string{"pull_request"}, + "X-GitHub-Delivery": []string{"deadbeef-1234"}, + "X-Hub-Signature-256": []string{sig}, + "Content-Type": []string{"application/json"}, + }) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestWebhook_GitHub_ValidSignature_TrailingSlash(t *testing.T) { + ts := testutil.NewTestServer(t) + const secret = "test-webhook-secret" + seedGitHubWebhookSecret(t, ts.DB, secret) + + body := []byte(`{"action":"opened","number":2}`) + sig := signGitHubPayload(secret, body) + + rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github/", body, http.Header{ + "X-GitHub-Event": []string{"pull_request"}, + "X-GitHub-Delivery": []string{"deadbeef-5678"}, + "X-Hub-Signature-256": []string{sig}, + "Content-Type": []string{"application/json"}, + }) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestWebhook_GitHub_NoSecretConfigured(t *testing.T) { + // With no github_app settings, webhook secret is empty → VerifySignature + // returns "secret is not configured" → 401. + ts := testutil.NewTestServer(t) + + body := []byte(`{"action":"opened"}`) + rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ + "X-GitHub-Event": []string{"pull_request"}, + "X-GitHub-Delivery": []string{"any"}, + "X-Hub-Signature-256": []string{"sha256=" + strings.Repeat("0", 64)}, + }) + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestWebhook_GitHub_MissingHeaders(t *testing.T) { + ts := testutil.NewTestServer(t) + const secret = "test-webhook-secret" + seedGitHubWebhookSecret(t, ts.DB, secret) + + body := []byte(`{"action":"opened"}`) + sig := signGitHubPayload(secret, body) + + // Valid signature but no event/delivery headers → 400. + rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ + "X-Hub-Signature-256": []string{sig}, + }) + require.Equal(t, http.StatusBadRequest, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/api/internal/handler/workspace_test.go b/api/internal/handler/workspace_test.go new file mode 100644 index 0000000..3a210f8 --- /dev/null +++ b/api/internal/handler/workspace_test.go @@ -0,0 +1,284 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkspace_List_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.GET("/api/users/me/workspaces/", "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestWorkspace_List_OnlyOwn(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + + other := testutil.CreateUser(t, ts.DB) + _ = testutil.CreateWorkspace(t, ts.DB, other.ID) // unrelated workspace + + session := testutil.LoginAs(t, ts.DB, owner) + rr := ts.GET("/api/users/me/workspaces/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + var list []map[string]any + list = testutil.DecodeJSON[[]map[string]any](t, rr) + require.Len(t, list, 1) + assert.Equal(t, w.Slug, list[0]["slug"]) +} + +func TestWorkspace_Create_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.POST("/api/workspaces/", map[string]any{ + "name": "Acme", + "slug": "acme-co", + "organization_size": "10-50", + }, session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, "Acme", body["name"]) + assert.Equal(t, "acme-co", body["slug"]) +} + +func TestWorkspace_Create_DuplicateSlug(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + _ = testutil.CreateWorkspace(t, ts.DB, user.ID) // factory uses ws-N slug + session := testutil.LoginAs(t, ts.DB, user) + + // First create succeeds. + rr := ts.POST("/api/workspaces/", map[string]any{ + "name": "First", + "slug": "duplicate-slug", + }, session) + require.Equal(t, http.StatusCreated, rr.Code) + + // Second create with same slug → 409. + rr2 := ts.POST("/api/workspaces/", map[string]any{ + "name": "Second", + "slug": "duplicate-slug", + }, session) + require.Equal(t, http.StatusConflict, rr2.Code) +} + +func TestWorkspace_Create_RequiresAuth(t *testing.T) { + ts := testutil.NewTestServer(t) + rr := ts.POST("/api/workspaces/", map[string]any{"name": "X"}, "") + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestWorkspace_GetBySlug_Success(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, user.ID) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/workspaces/"+w.Slug+"/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, w.Slug, body["slug"]) +} + +func TestWorkspace_GetBySlug_NonMember404(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + + stranger := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, stranger) + + rr := ts.GET("/api/workspaces/"+w.Slug+"/", session) + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestWorkspace_Update_Name(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, user.ID) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.PATCH("/api/workspaces/"+w.Slug+"/", map[string]any{ + "name": "New Name", + }, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + assert.Equal(t, "New Name", testutil.MustJSONMap(t, rr)["name"]) +} + +func TestWorkspace_Delete_OwnerOnly(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + + other := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.ID, other.ID, testutil.RoleMember) + otherSession := testutil.LoginAs(t, ts.DB, other) + + // Non-owner cannot delete. + rr := ts.DELETE("/api/workspaces/"+w.Slug+"/", otherSession) + assert.NotEqual(t, http.StatusNoContent, rr.Code, "non-owner should not be able to delete") + + // Owner can. + ownerSession := testutil.LoginAs(t, ts.DB, owner) + rr2 := ts.DELETE("/api/workspaces/"+w.Slug+"/", ownerSession) + require.Equal(t, http.StatusNoContent, rr2.Code, "body=%s", rr2.Body.String()) +} + +func TestWorkspace_SlugCheck(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, user.ID) + session := testutil.LoginAs(t, ts.DB, user) + + // Available + rr := ts.GET("/api/workspace-slug-check/?slug=brand-new-slug", session) + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, true, testutil.MustJSONMap(t, rr)["status"]) + + // Taken + rr2 := ts.GET("/api/workspace-slug-check/?slug="+w.Slug, session) + require.Equal(t, http.StatusOK, rr2.Code) + assert.Equal(t, false, testutil.MustJSONMap(t, rr2)["status"]) +} + +func TestWorkspace_ListMembers(t *testing.T) { + ts := testutil.NewTestServer(t) + user := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, user.ID) + other := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.ID, other.ID, testutil.RoleMember) + session := testutil.LoginAs(t, ts.DB, user) + + rr := ts.GET("/api/workspaces/"+w.Slug+"/members/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + list := testutil.DecodeJSON[[]map[string]any](t, rr) + assert.Len(t, list, 2) +} + +func TestWorkspace_UpdateMember_Role(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + other := testutil.CreateUser(t, ts.DB) + m := testutil.AddWorkspaceMember(t, ts.DB, w.ID, other.ID, testutil.RoleMember) + session := testutil.LoginAs(t, ts.DB, owner) + + rr := ts.PATCH("/api/workspaces/"+w.Slug+"/members/"+m.ID.String()+"/", map[string]any{ + "role": testutil.RoleAdmin, + }, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.EqualValues(t, testutil.RoleAdmin, body["role"]) +} + +func TestWorkspace_DeleteMember(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + other := testutil.CreateUser(t, ts.DB) + m := testutil.AddWorkspaceMember(t, ts.DB, w.ID, other.ID, testutil.RoleMember) + session := testutil.LoginAs(t, ts.DB, owner) + + rr := ts.DELETE("/api/workspaces/"+w.Slug+"/members/"+m.ID.String()+"/", session) + require.Equal(t, http.StatusNoContent, rr.Code, "body=%s", rr.Body.String()) +} + +func TestWorkspace_Leave_OwnerCannot(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + session := testutil.LoginAs(t, ts.DB, owner) + + rr := ts.POST("/api/workspaces/"+w.Slug+"/members/leave/", nil, session) + require.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestWorkspace_Leave_MemberCan(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + other := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.ID, other.ID, testutil.RoleMember) + session := testutil.LoginAs(t, ts.DB, other) + + rr := ts.POST("/api/workspaces/"+w.Slug+"/members/leave/", nil, session) + require.Equal(t, http.StatusNoContent, rr.Code, "body=%s", rr.Body.String()) +} + +func TestWorkspace_Invitations_CreateListDelete(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + session := testutil.LoginAs(t, ts.DB, owner) + + // Create + rr := ts.POST("/api/workspaces/"+w.Slug+"/invitations/", map[string]any{ + "email": "newhire@test.local", + "role": testutil.RoleMember, + }, session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + created := testutil.MustJSONMap(t, rr) + inviteID, _ := created["id"].(string) + require.NotEmpty(t, inviteID) + + // List + rr2 := ts.GET("/api/workspaces/"+w.Slug+"/invitations/", session) + require.Equal(t, http.StatusOK, rr2.Code) + list := testutil.DecodeJSON[[]map[string]any](t, rr2) + assert.Len(t, list, 1) + + // Get one + rr3 := ts.GET("/api/workspaces/"+w.Slug+"/invitations/"+inviteID+"/", session) + require.Equal(t, http.StatusOK, rr3.Code) + + // Delete + rr4 := ts.DELETE("/api/workspaces/"+w.Slug+"/invitations/"+inviteID+"/", session) + require.Equal(t, http.StatusNoContent, rr4.Code) +} + +func TestWorkspace_JoinByToken(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + invitee := testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("joinme@test.local")) + inv := testutil.CreateWorkspaceInvite(t, ts.DB, w.ID, "joinme@test.local", "join-tok-123") + + session := testutil.LoginAs(t, ts.DB, invitee) + rr := ts.POST("/api/workspaces/join/", map[string]any{"token": inv.Token}, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + body := testutil.MustJSONMap(t, rr) + assert.Equal(t, w.Slug, body["slug"]) +} + +func TestWorkspace_JoinByInvite_PathID(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + invitee := testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("byid@test.local")) + inv := testutil.CreateWorkspaceInvite(t, ts.DB, w.ID, "byid@test.local", "byid-tok-456") + + session := testutil.LoginAs(t, ts.DB, invitee) + rr := ts.POST("/api/workspaces/"+w.Slug+"/invitations/"+inv.ID.String()+"/join/", nil, session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} + +func TestWorkspace_ListUserInvitations(t *testing.T) { + ts := testutil.NewTestServer(t) + owner := testutil.CreateUser(t, ts.DB) + w := testutil.CreateWorkspace(t, ts.DB, owner.ID) + user := testutil.CreateUser(t, ts.DB, testutil.WithUserEmail("inv@test.local")) + _ = testutil.CreateWorkspaceInvite(t, ts.DB, w.ID, "inv@test.local", "inv-tok-789") + + session := testutil.LoginAs(t, ts.DB, user) + rr := ts.GET("/api/users/me/workspaces/invitations/", session) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) +} diff --git a/api/internal/testutil/auth.go b/api/internal/testutil/auth.go new file mode 100644 index 0000000..bdbf5bf --- /dev/null +++ b/api/internal/testutil/auth.go @@ -0,0 +1,35 @@ +package testutil + +import ( + "context" + "crypto/rand" + "encoding/hex" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "gorm.io/gorm" +) + +// LoginAs creates a fresh session row for the given user and returns the +// 40-char hex session key. Pass the key as the last argument to ts.GET / +// POST / PATCH / PUT / DELETE (or ts.Do) to authenticate the request via +// the session_id cookie. +// +// This bypasses bcrypt+SignIn for speed but exercises the same persistence +// path that production auth.Service.createSession uses (via SessionStore). +func LoginAs(t testing.TB, db *gorm.DB, user *model.User) string { + t.Helper() + if user == nil { + t.Fatal("LoginAs: user is nil") + } + var raw [20]byte + if _, err := rand.Read(raw[:]); err != nil { + t.Fatalf("LoginAs: rand: %v", err) + } + key := hex.EncodeToString(raw[:]) + if err := store.NewSessionStore(db).Create(context.Background(), key, user.ID); err != nil { + t.Fatalf("LoginAs: create session: %v", err) + } + return key +} diff --git a/api/internal/testutil/factory.go b/api/internal/testutil/factory.go new file mode 100644 index 0000000..ca96d41 --- /dev/null +++ b/api/internal/testutil/factory.go @@ -0,0 +1,387 @@ +package testutil + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// TestPassword is the password used by all factory-created users. +// It is hashed once at process start (cost 4) so factories don't pay +// the production-cost bcrypt overhead on every CreateUser. +const TestPassword = "TestPass123!" + +var ( + hashOnce sync.Once + cached string + counter uint64 +) + +// testPasswordHash returns a precomputed bcrypt hash of TestPassword. +// CompareHashAndPassword does not care about the cost, so SignIn tests +// using this hash still validate correctly. +func testPasswordHash() string { + hashOnce.Do(func() { + h, err := bcrypt.GenerateFromPassword([]byte(TestPassword), bcrypt.MinCost) + if err != nil { + panic(fmt.Errorf("bcrypt: %w", err)) + } + cached = string(h) + }) + return cached +} + +// nextN returns a unique-per-process counter for generating distinct emails/slugs. +func nextN() uint64 { return atomic.AddUint64(&counter, 1) } + +// UserOpt customizes a factory-built user before insert. +type UserOpt func(*model.User) + +func WithUserEmail(email string) UserOpt { + return func(u *model.User) { + e := strings.ToLower(strings.TrimSpace(email)) + u.Email = &e + } +} + +func WithUserName(first, last string) UserOpt { + return func(u *model.User) { + u.FirstName = first + u.LastName = last + u.DisplayName = strings.TrimSpace(first + " " + last) + } +} + +func WithUserInactive() UserOpt { + return func(u *model.User) { u.IsActive = false } +} + +func WithUserPasswordAutoset() UserOpt { + return func(u *model.User) { u.IsPasswordAutoset = true } +} + +// CreateUser inserts a user with TestPassword as the password and a unique +// email/username. Use WithUser* options to override. +func CreateUser(t testing.TB, db *gorm.DB, opts ...UserOpt) *model.User { + t.Helper() + n := nextN() + email := fmt.Sprintf("user%d@test.local", n) + u := &model.User{ + Username: fmt.Sprintf("user%d", n), + Email: &email, + Password: testPasswordHash(), + FirstName: "Test", + LastName: fmt.Sprintf("User%d", n), + DisplayName: fmt.Sprintf("Test User%d", n), + IsActive: true, + } + for _, opt := range opts { + opt(u) + } + wantInactive := !u.IsActive + wantAutoset := u.IsPasswordAutoset + + if err := store.NewUserStore(db).Create(context.Background(), u); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // GORM lets the column DEFAULT win when struct fields equal Go's zero value. + // is_active defaults to true and is_password_autoset to false in migrations, + // so explicit overrides need a follow-up UPDATE to actually stick. + updates := map[string]any{} + if wantInactive { + updates["is_active"] = false + } + if wantAutoset { + updates["is_password_autoset"] = true + } + if len(updates) > 0 { + if err := db.Model(u).Updates(updates).Error; err != nil { + t.Fatalf("CreateUser: post-create update: %v", err) + } + // Reload so the caller sees the persisted state. + if wantInactive { + u.IsActive = false + } + if wantAutoset { + u.IsPasswordAutoset = true + } + } + return u +} + +// Workspace member roles (matches int16 values used by the API). +const ( + RoleGuest int16 = 5 + RoleMember int16 = 10 + RoleAdmin int16 = 15 + RoleOwner int16 = 20 +) + +// CreateWorkspace inserts a workspace owned by `ownerID` and seeds the +// owner as a WorkspaceMember with RoleOwner. Returns the workspace. +func CreateWorkspace(t testing.TB, db *gorm.DB, ownerID uuid.UUID) *model.Workspace { + t.Helper() + n := nextN() + w := &model.Workspace{ + Name: fmt.Sprintf("Workspace %d", n), + Slug: fmt.Sprintf("ws-%d", n), + OwnerID: ownerID, + } + ws := store.NewWorkspaceStore(db) + if err := ws.Create(context.Background(), w); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := ws.AddMember(context.Background(), &model.WorkspaceMember{ + WorkspaceID: w.ID, + MemberID: ownerID, + Role: RoleOwner, + }); err != nil { + t.Fatalf("CreateWorkspace: add owner member: %v", err) + } + return w +} + +// AddWorkspaceMember adds a user to a workspace at the given role. +func AddWorkspaceMember(t testing.TB, db *gorm.DB, workspaceID, userID uuid.UUID, role int16) *model.WorkspaceMember { + t.Helper() + m := &model.WorkspaceMember{ + WorkspaceID: workspaceID, + MemberID: userID, + Role: role, + } + if err := store.NewWorkspaceStore(db).AddMember(context.Background(), m); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + return m +} + +// CreateProject inserts a project and adds `leadID` as a project member with +// RoleAdmin. Returns the project. +func CreateProject(t testing.TB, db *gorm.DB, workspaceID, leadID uuid.UUID) *model.Project { + t.Helper() + n := nextN() + leadCopy := leadID + p := &model.Project{ + Name: fmt.Sprintf("Project %d", n), + Identifier: fmt.Sprintf("PRJ%d", n%10000), + Slug: fmt.Sprintf("prj-%d", n), + WorkspaceID: workspaceID, + ProjectLeadID: &leadCopy, + CreatedByID: &leadCopy, + } + ps := store.NewProjectStore(db) + if err := ps.Create(context.Background(), p); err != nil { + t.Fatalf("CreateProject: %v", err) + } + if err := ps.AddProjectMember(context.Background(), &model.ProjectMember{ + ProjectID: p.ID, + WorkspaceID: workspaceID, + MemberID: &leadCopy, + Role: RoleAdmin, + }); err != nil { + t.Fatalf("CreateProject: add lead member: %v", err) + } + return p +} + +// AddProjectMember adds a user to a project at the given role. +func AddProjectMember(t testing.TB, db *gorm.DB, projectID, workspaceID, userID uuid.UUID, role int16) *model.ProjectMember { + t.Helper() + uid := userID + m := &model.ProjectMember{ + ProjectID: projectID, + WorkspaceID: workspaceID, + MemberID: &uid, + Role: role, + } + if err := store.NewProjectStore(db).AddProjectMember(context.Background(), m); err != nil { + t.Fatalf("AddProjectMember: %v", err) + } + return m +} + +// CreateState inserts a workflow state for the project. +func CreateState(t testing.TB, db *gorm.DB, projectID, workspaceID uuid.UUID) *model.State { + t.Helper() + n := nextN() + s := &model.State{ + Name: fmt.Sprintf("State %d", n), + Color: "#abcdef", + Group: "backlog", + ProjectID: projectID, + WorkspaceID: workspaceID, + } + if err := store.NewStateStore(db).Create(context.Background(), s); err != nil { + t.Fatalf("CreateState: %v", err) + } + return s +} + +// CreateLabel inserts an issue label. +func CreateLabel(t testing.TB, db *gorm.DB, projectID, workspaceID uuid.UUID) *model.Label { + t.Helper() + n := nextN() + pid := projectID + l := &model.Label{ + Name: fmt.Sprintf("Label %d", n), + Color: "#ff0000", + ProjectID: &pid, + WorkspaceID: workspaceID, + } + if err := store.NewLabelStore(db).Create(context.Background(), l); err != nil { + t.Fatalf("CreateLabel: %v", err) + } + return l +} + +// CreateIssue inserts an issue. createdBy is required because IssueStore +// records it on activity logs. +func CreateIssue(t testing.TB, db *gorm.DB, projectID, workspaceID, createdByID uuid.UUID) *model.Issue { + t.Helper() + n := nextN() + cb := createdByID + i := &model.Issue{ + Name: fmt.Sprintf("Issue %d", n), + Priority: "none", + ProjectID: projectID, + WorkspaceID: workspaceID, + CreatedByID: &cb, + SequenceID: int(n), + } + if err := store.NewIssueStore(db).Create(context.Background(), i); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + return i +} + +// CreateCycle inserts a cycle owned by `ownedByID`. +func CreateCycle(t testing.TB, db *gorm.DB, projectID, workspaceID, ownedByID uuid.UUID) *model.Cycle { + t.Helper() + n := nextN() + c := &model.Cycle{ + Name: fmt.Sprintf("Cycle %d", n), + Status: "draft", + ProjectID: projectID, + WorkspaceID: workspaceID, + OwnedByID: ownedByID, + } + if err := store.NewCycleStore(db).Create(context.Background(), c); err != nil { + t.Fatalf("CreateCycle: %v", err) + } + return c +} + +// CreateModule inserts a module. +func CreateModule(t testing.TB, db *gorm.DB, projectID, workspaceID uuid.UUID) *model.Module { + t.Helper() + n := nextN() + m := &model.Module{ + Name: fmt.Sprintf("Module %d", n), + Status: "backlog", + ProjectID: projectID, + WorkspaceID: workspaceID, + } + if err := store.NewModuleStore(db).Create(context.Background(), m); err != nil { + t.Fatalf("CreateModule: %v", err) + } + return m +} + +// CreatePage inserts a page in the workspace. +func CreatePage(t testing.TB, db *gorm.DB, workspaceID, ownedByID uuid.UUID) *model.Page { + t.Helper() + n := nextN() + cb := ownedByID + p := &model.Page{ + Name: fmt.Sprintf("Page %d", n), + WorkspaceID: workspaceID, + OwnedByID: ownedByID, + Access: model.PageAccessPublic, + CreatedByID: &cb, + } + if err := store.NewPageStore(db).Create(context.Background(), p); err != nil { + t.Fatalf("CreatePage: %v", err) + } + return p +} + +// CreateView inserts a workspace-level issue view owned by `ownedByID`. +func CreateView(t testing.TB, db *gorm.DB, workspaceID, ownedByID uuid.UUID) *model.IssueView { + t.Helper() + n := nextN() + v := &model.IssueView{ + Name: fmt.Sprintf("View %d", n), + WorkspaceID: workspaceID, + OwnedByID: ownedByID, + Access: 1, // public + Query: model.JSONMap{}, + } + if err := store.NewIssueViewStore(db).Create(context.Background(), v); err != nil { + t.Fatalf("CreateView: %v", err) + } + return v +} + +// CreateComment inserts a comment on an issue, authored by `authorID`. +func CreateComment(t testing.TB, db *gorm.DB, issueID, projectID, workspaceID, authorID uuid.UUID) *model.IssueComment { + t.Helper() + cb := authorID + c := &model.IssueComment{ + IssueID: issueID, + ProjectID: projectID, + WorkspaceID: workspaceID, + Comment: "test comment " + time.Now().Format(time.RFC3339Nano), + Access: "INTERNAL", + CreatedByID: &cb, + } + if err := store.NewCommentStore(db).Create(context.Background(), c); err != nil { + t.Fatalf("CreateComment: %v", err) + } + return c +} + +// CreateWorkspaceInvite inserts an invitation for `email` to join `workspaceID`. +func CreateWorkspaceInvite(t testing.TB, db *gorm.DB, workspaceID uuid.UUID, email, token string) *model.WorkspaceMemberInvite { + t.Helper() + inv := &model.WorkspaceMemberInvite{ + WorkspaceID: workspaceID, + Email: strings.ToLower(strings.TrimSpace(email)), + Token: token, + Role: RoleMember, + } + if err := store.NewWorkspaceInviteStore(db).Create(context.Background(), inv); err != nil { + t.Fatalf("CreateWorkspaceInvite: %v", err) + } + return inv +} + +// SeededWorld is a convenience bundle of common test fixtures: an admin user, +// a workspace they own, a project they lead, plus a session key for the user. +type SeededWorld struct { + User *model.User + Workspace *model.Workspace + Project *model.Project + Session string +} + +// SeedWorld creates the typical fixture set used by most handler tests: +// one user with a session, one workspace they own, one project they lead. +func SeedWorld(t testing.TB, db *gorm.DB) SeededWorld { + t.Helper() + u := CreateUser(t, db) + w := CreateWorkspace(t, db, u.ID) + p := CreateProject(t, db, w.ID, u.ID) + s := LoginAs(t, db, u) + return SeededWorld{User: u, Workspace: w, Project: p, Session: s} +} diff --git a/api/internal/testutil/http.go b/api/internal/testutil/http.go new file mode 100644 index 0000000..757534a --- /dev/null +++ b/api/internal/testutil/http.go @@ -0,0 +1,129 @@ +package testutil + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Devlaner/devlane/api/internal/middleware" +) + +// Do dispatches an HTTP request through the gin router and returns the recorder. +// body may be nil (no body), a string, []byte, io.Reader, or any JSON-marshallable value. +// sessionKey, if non-empty, is attached as the session_id cookie. +func (ts *TestServer) Do(method, path string, body any, sessionKey string) *httptest.ResponseRecorder { + ts.T.Helper() + + var reader io.Reader + contentType := "" + switch v := body.(type) { + case nil: + reader = nil + case io.Reader: + reader = v + case string: + reader = strings.NewReader(v) + case []byte: + reader = bytes.NewReader(v) + default: + b, err := json.Marshal(v) + if err != nil { + ts.T.Fatalf("marshal body: %v", err) + } + reader = bytes.NewReader(b) + contentType = "application/json" + } + + req := httptest.NewRequest(method, path, reader) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if sessionKey != "" { + req.AddCookie(&http.Cookie{Name: middleware.SessionCookieName, Value: sessionKey}) + } + + rr := httptest.NewRecorder() + ts.Router.ServeHTTP(rr, req) + return rr +} + +// DoWithHeaders is Do plus arbitrary header injection (e.g. Authorization: Bearer). +func (ts *TestServer) DoWithHeaders(method, path string, body any, headers http.Header) *httptest.ResponseRecorder { + ts.T.Helper() + + var reader io.Reader + contentType := "" + switch v := body.(type) { + case nil: + reader = nil + case io.Reader: + reader = v + case string: + reader = strings.NewReader(v) + case []byte: + reader = bytes.NewReader(v) + default: + b, err := json.Marshal(v) + if err != nil { + ts.T.Fatalf("marshal body: %v", err) + } + reader = bytes.NewReader(b) + contentType = "application/json" + } + + req := httptest.NewRequest(method, path, reader) + if contentType != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", contentType) + } + for k, vs := range headers { + for _, v := range vs { + req.Header.Add(k, v) + } + } + + rr := httptest.NewRecorder() + ts.Router.ServeHTTP(rr, req) + return rr +} + +func (ts *TestServer) GET(path, sessionKey string) *httptest.ResponseRecorder { + return ts.Do(http.MethodGet, path, nil, sessionKey) +} + +func (ts *TestServer) POST(path string, body any, sessionKey string) *httptest.ResponseRecorder { + return ts.Do(http.MethodPost, path, body, sessionKey) +} + +func (ts *TestServer) PUT(path string, body any, sessionKey string) *httptest.ResponseRecorder { + return ts.Do(http.MethodPut, path, body, sessionKey) +} + +func (ts *TestServer) PATCH(path string, body any, sessionKey string) *httptest.ResponseRecorder { + return ts.Do(http.MethodPatch, path, body, sessionKey) +} + +func (ts *TestServer) DELETE(path, sessionKey string) *httptest.ResponseRecorder { + return ts.Do(http.MethodDelete, path, nil, sessionKey) +} + +// DecodeJSON unmarshals a recorder body into T. Fatals on error. +func DecodeJSON[T any](t testing.TB, rr *httptest.ResponseRecorder) T { + t.Helper() + var v T + if rr.Body.Len() == 0 { + return v + } + if err := json.Unmarshal(rr.Body.Bytes(), &v); err != nil { + t.Fatalf("decode JSON: %v\nbody=%s", err, rr.Body.String()) + } + return v +} + +// MustJSONMap is a convenience for tests that just want to peek at fields by name. +func MustJSONMap(t testing.TB, rr *httptest.ResponseRecorder) map[string]any { + return DecodeJSON[map[string]any](t, rr) +} diff --git a/api/internal/testutil/pg.go b/api/internal/testutil/pg.go new file mode 100644 index 0000000..21849c6 --- /dev/null +++ b/api/internal/testutil/pg.go @@ -0,0 +1,131 @@ +// Package testutil provides shared infrastructure for HTTP-level API tests. +// +// Usage in a test file: +// +// func TestSomething(t *testing.T) { +// ts := testutil.NewTestServer(t) +// user := testutil.CreateUser(t, ts.DB) +// sessionKey := testutil.LoginAs(t, ts.DB, user) +// rr := ts.GET("/api/users/me/", sessionKey) +// require.Equal(t, http.StatusOK, rr.Code) +// } +// +// One Postgres testcontainer is started per `go test` package invocation +// (lazy, behind sync.Once). Tests share the same database; NewTestServer +// truncates every table at the start of each test for isolation. +package testutil + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/Devlaner/devlane/api/internal/config" + "github.com/Devlaner/devlane/api/internal/database" + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var ( + pgOnce sync.Once + pgDB *gorm.DB + pgErr error +) + +// PG returns the shared test Postgres database, starting the container on +// first call. Migrations run once. Subsequent calls reuse the connection. +func PG(t testing.TB) *gorm.DB { + t.Helper() + pgOnce.Do(initPG) + if pgErr != nil { + t.Fatalf("postgres testcontainer: %v", pgErr) + } + return pgDB +} + +func initPG() { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + container, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("devlane_test"), + tcpostgres.WithUsername("postgres"), + tcpostgres.WithPassword("postgres"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(90*time.Second), + ), + ) + if err != nil { + pgErr = fmt.Errorf("start container: %w", err) + return + } + + host, err := container.Host(ctx) + if err != nil { + pgErr = fmt.Errorf("container host: %w", err) + return + } + port, err := container.MappedPort(ctx, "5432/tcp") + if err != nil { + pgErr = fmt.Errorf("container port: %w", err) + return + } + + cfg := &config.Config{ + DBHost: host, + DBPort: port.Port(), + DBUser: "postgres", + DBPassword: "postgres", + DBName: "devlane_test", + DBSSLMode: "disable", + MigrationsPath: migrationsPath(), + } + + silentLog := slog.New(slog.NewTextHandler(discardWriter{}, &slog.HandlerOptions{Level: slog.LevelError})) + if err := database.RunMigrations(cfg, silentLog); err != nil { + pgErr = fmt.Errorf("run migrations: %w", err) + return + } + + dsn := cfg.DSN() + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + pgErr = fmt.Errorf("open gorm: %w", err) + return + } + + pgDB = db +} + +// migrationsPath returns the absolute path to api/migrations/ derived from +// this file's source location, so tests work regardless of CWD. Returned +// with forward slashes — golang-migrate's file source prefixes "file://" +// and then strips it, so "C:/path" (Windows) and "/path" (Unix) both work. +func migrationsPath() string { + _, file, _, _ := runtime.Caller(0) + // file = .../api/internal/testutil/pg.go + apiDir := filepath.Join(filepath.Dir(file), "..", "..") + abs, err := filepath.Abs(filepath.Join(apiDir, "migrations")) + if err != nil { + abs = filepath.Join(apiDir, "migrations") + } + return filepath.ToSlash(abs) +} + +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } diff --git a/api/internal/testutil/router.go b/api/internal/testutil/router.go new file mode 100644 index 0000000..c49bb4e --- /dev/null +++ b/api/internal/testutil/router.go @@ -0,0 +1,56 @@ +package testutil + +import ( + "io" + "log/slog" + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/router" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// TestServer bundles everything an HTTP-level test needs: the shared DB +// (already truncated to a clean state) and a real *gin.Engine built from +// router.New, so requests exercise the full middleware + handler stack. +type TestServer struct { + T testing.TB + DB *gorm.DB + Router *gin.Engine +} + +// NewTestServer brings up the shared Postgres container (lazy), truncates +// every table, and returns a fresh router. The MagicCodeSecret is fixed so +// magic-code tests are deterministic; CORSAllowOrigin is empty so the CORS +// middleware doesn't interfere. +// +// Note: gin runs in ReleaseMode here because router.New explicitly sets it +// — calling gin.SetMode in this function would be overwritten immediately. +func NewTestServer(t testing.TB) *TestServer { + t.Helper() + + db := PG(t) + TruncateAll(t, db) + + cfg := router.Config{ + Log: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})), + DB: db, + MagicCodeSecret: "test-secret-32-bytes-long-enough!", + AppBaseURL: "http://localhost:5173", + APIPublicURL: "http://localhost:8080", + } + return &TestServer{ + T: t, + DB: db, + Router: router.New(cfg), + } +} + +// ServeHTTP makes TestServer satisfy http.Handler so tests can also use +// httptest.NewServer if they need a real listening port. +func (ts *TestServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ts.Router.ServeHTTP(w, r) +} + +var _ http.Handler = (*TestServer)(nil) diff --git a/api/internal/testutil/truncate.go b/api/internal/testutil/truncate.go new file mode 100644 index 0000000..e9ee422 --- /dev/null +++ b/api/internal/testutil/truncate.go @@ -0,0 +1,61 @@ +package testutil + +import ( + "strings" + "sync" + "testing" + + "gorm.io/gorm" +) + +var ( + tableListMu sync.Mutex + cachedTables []string +) + +// TruncateAll empties every public-schema table except schema_migrations. +// Uses a single TRUNCATE … RESTART IDENTITY CASCADE statement so foreign +// key constraints don't get in the way of arbitrary truncation order. +func TruncateAll(t testing.TB, db *gorm.DB) { + t.Helper() + + tables, err := publicTables(db) + if err != nil { + t.Fatalf("list tables: %v", err) + } + if len(tables) == 0 { + return + } + + quoted := make([]string, 0, len(tables)) + for _, name := range tables { + quoted = append(quoted, `"`+name+`"`) + } + stmt := "TRUNCATE TABLE " + strings.Join(quoted, ", ") + " RESTART IDENTITY CASCADE" + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("truncate: %v", err) + } +} + +func publicTables(db *gorm.DB) ([]string, error) { + tableListMu.Lock() + defer tableListMu.Unlock() + + if cachedTables != nil { + return cachedTables, nil + } + + var tables []string + err := db.Raw(` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename != 'schema_migrations' + ORDER BY tablename + `).Scan(&tables).Error + if err != nil { + return nil, err + } + cachedTables = tables + return tables, nil +}