diff --git a/go.mod b/go.mod index 8fb0e93cb..cd7f5b5e1 100644 --- a/go.mod +++ b/go.mod @@ -21,11 +21,14 @@ require ( github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 github.com/yuin/goldmark v1.7.13 - golang.org/x/crypto v0.42.0 + golang.org/x/crypto v0.43.0 golang.org/x/mod v0.28.0 - golang.org/x/net v0.43.0 + golang.org/x/net v0.45.0 golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.17.0 google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 @@ -35,23 +38,64 @@ require ( require ( cel.dev/expr v0.24.0 // indirect + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // 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/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // 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.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.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-viper/mapstructure/v2 v2.2.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // 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.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/ncruces/go-strftime v0.1.9 // 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.3 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect @@ -86,8 +130,8 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.9 gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index c6e7b022e..9398efe12 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,16 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-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-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/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/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= @@ -44,23 +52,52 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47 github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +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/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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +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.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +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-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= @@ -71,6 +108,7 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -83,8 +121,18 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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= @@ -97,12 +145,40 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= +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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +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/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +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.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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.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.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -110,13 +186,19 @@ 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-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= @@ -134,24 +216,45 @@ github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8w github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0 h1:P9Txfy5Jothx2wFdcus0QoSmX/PKSIXZxrTbZPVJswA= +github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0/go.mod h1:oZPHHqJqXG7FD8OB/yWH7gLnDvZUlFHAVJNrGftL+eg= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= 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/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= @@ -160,12 +263,14 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 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/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -173,22 +278,31 @@ golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g= @@ -205,6 +319,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s= modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= diff --git a/plugin/filter/schema.go b/plugin/filter/schema.go index 21fae7423..8cfd2d915 100644 --- a/plugin/filter/schema.go +++ b/plugin/filter/schema.go @@ -125,8 +125,11 @@ func NewSchema() Schema { Type: FieldTypeTimestamp, Column: Column{Table: "memo", Name: "created_ts"}, Expressions: map[DialectName]string{ - DialectMySQL: "UNIX_TIMESTAMP(%s)", - DialectPostgres: "EXTRACT(EPOCH FROM TO_TIMESTAMP(%s))", + // MySQL stores created_ts as TIMESTAMP, needs conversion to epoch + DialectMySQL: "UNIX_TIMESTAMP(%s)", + // PostgreSQL and SQLite store created_ts as BIGINT (epoch), no conversion needed + DialectPostgres: "%s", + DialectSQLite: "%s", }, }, "updated_ts": { @@ -135,8 +138,11 @@ func NewSchema() Schema { Type: FieldTypeTimestamp, Column: Column{Table: "memo", Name: "updated_ts"}, Expressions: map[DialectName]string{ - DialectMySQL: "UNIX_TIMESTAMP(%s)", - DialectPostgres: "EXTRACT(EPOCH FROM TO_TIMESTAMP(%s))", + // MySQL stores updated_ts as TIMESTAMP, needs conversion to epoch + DialectMySQL: "UNIX_TIMESTAMP(%s)", + // PostgreSQL and SQLite store updated_ts as BIGINT (epoch), no conversion needed + DialectPostgres: "%s", + DialectSQLite: "%s", }, }, "pinned": { @@ -267,15 +273,18 @@ func NewAttachmentSchema() Schema { Type: FieldTypeTimestamp, Column: Column{Table: "resource", Name: "created_ts"}, Expressions: map[DialectName]string{ - DialectMySQL: "UNIX_TIMESTAMP(%s)", - DialectPostgres: "EXTRACT(EPOCH FROM TO_TIMESTAMP(%s))", + // MySQL stores created_ts as TIMESTAMP, needs conversion to epoch + DialectMySQL: "UNIX_TIMESTAMP(%s)", + // PostgreSQL and SQLite store created_ts as BIGINT (epoch), no conversion needed + DialectPostgres: "%s", + DialectSQLite: "%s", }, }, - "memo": { - Name: "memo", + "memo_id": { + Name: "memo_id", Kind: FieldKindScalar, - Type: FieldTypeString, - Column: Column{Table: "resource", Name: "memo_uid"}, + Type: FieldTypeInt, + Column: Column{Table: "resource", Name: "memo_id"}, Expressions: map[DialectName]string{}, AllowedComparisonOps: map[ComparisonOperator]bool{ CompareEq: true, @@ -288,7 +297,7 @@ func NewAttachmentSchema() Schema { cel.Variable("filename", cel.StringType), cel.Variable("mime_type", cel.StringType), cel.Variable("create_time", cel.IntType), - cel.Variable("memo", cel.StringType), + cel.Variable("memo_id", cel.IntType), nowFunction, } diff --git a/store/db/mysql/mysql.go b/store/db/mysql/mysql.go index 2862d8722..ee98e11a1 100644 --- a/store/db/mysql/mysql.go +++ b/store/db/mysql/mysql.go @@ -50,7 +50,7 @@ func (d *DB) Close() error { func (d *DB) IsInitialized(ctx context.Context) (bool, error) { var exists bool - err := d.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE TABLE_NAME = 'memo' AND TABLE_TYPE = 'BASE TABLE')").Scan(&exists) + err := d.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'memo' AND TABLE_TYPE = 'BASE TABLE')").Scan(&exists) if err != nil { return false, errors.Wrap(err, "failed to check if database is initialized") } diff --git a/store/db/postgres/memo_filter_test.go b/store/db/postgres/memo_filter_test.go index 50d94af42..b6ac67309 100644 --- a/store/db/postgres/memo_filter_test.go +++ b/store/db/postgres/memo_filter_test.go @@ -93,7 +93,7 @@ func TestConvertExprToSQL(t *testing.T) { }, { filter: `created_ts > now() - 60 * 60 * 24`, - want: "EXTRACT(EPOCH FROM TO_TIMESTAMP(memo.created_ts)) > $1", + want: "memo.created_ts > $1", args: []any{time.Now().Unix() - 60*60*24}, }, { diff --git a/store/db/postgres/postgres.go b/store/db/postgres/postgres.go index 4cf017eb7..1744a2cb5 100644 --- a/store/db/postgres/postgres.go +++ b/store/db/postgres/postgres.go @@ -49,7 +49,7 @@ func (d *DB) Close() error { func (d *DB) IsInitialized(ctx context.Context) (bool, error) { var exists bool - err := d.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'memo' AND table_type = 'BASE TABLE')").Scan(&exists) + err := d.db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_catalog = current_database() AND table_name = 'memo' AND table_type = 'BASE TABLE')").Scan(&exists) if err != nil { return false, errors.Wrap(err, "failed to check if database is initialized") } diff --git a/store/test/activity_test.go b/store/test/activity_test.go index 96ab81023..1328199e8 100644 --- a/store/test/activity_test.go +++ b/store/test/activity_test.go @@ -32,3 +32,70 @@ func TestActivityStore(t *testing.T) { require.Equal(t, activity, activities[0]) ts.Close() } + +func TestActivityGetByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + // Get activity by ID + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, activity.ID, found.ID) + + // Get non-existent activity + nonExistentID := int32(99999) + notFound, err := ts.GetActivity(ctx, &store.FindActivity{ID: &nonExistentID}) + require.NoError(t, err) + require.Nil(t, notFound) + + ts.Close() +} + +func TestActivityListMultiple(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create multiple activities + _, err = ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + _, err = ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + // List all activities + allActivities, err := ts.ListActivities(ctx, &store.FindActivity{}) + require.NoError(t, err) + require.Equal(t, 2, len(allActivities)) + + // List by type + commentType := store.ActivityTypeMemoComment + commentActivities, err := ts.ListActivities(ctx, &store.FindActivity{Type: &commentType}) + require.NoError(t, err) + require.Equal(t, 2, len(commentActivities)) + require.Equal(t, store.ActivityTypeMemoComment, commentActivities[0].Type) + + ts.Close() +} diff --git a/store/test/attachment_filter_test.go b/store/test/attachment_filter_test.go new file mode 100644 index 000000000..a46d31a82 --- /dev/null +++ b/store/test/attachment_filter_test.go @@ -0,0 +1,346 @@ +package test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// Filename Field Tests +// Schema: filename (string, supports contains) +// ============================================================================= + +func TestAttachmentFilterFilenameContains(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.pdf").MimeType("application/pdf")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png")) + + // Test: filename.contains("report") - single match + attachments := tc.ListWithFilter(`filename.contains("report")`) + require.Len(t, attachments, 1) + require.Contains(t, attachments[0].Filename, "report") + + // Test: filename.contains(".pdf") - multiple matches + attachments = tc.ListWithFilter(`filename.contains(".pdf")`) + require.Len(t, attachments, 2) + + // Test: filename.contains("nonexistent") - no matches + attachments = tc.ListWithFilter(`filename.contains("nonexistent")`) + require.Len(t, attachments, 0) +} + +func TestAttachmentFilterFilenameSpecialCharacters(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID). + Filename("file_with-special.chars@2024.pdf").MimeType("application/pdf")) + + // Test: filename.contains with underscore + attachments := tc.ListWithFilter(`filename.contains("_with")`) + require.Len(t, attachments, 1) + + // Test: filename.contains with @ + attachments = tc.ListWithFilter(`filename.contains("@2024")`) + require.Len(t, attachments, 1) +} + +func TestAttachmentFilterFilenameUnicode(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID). + Filename("document_ζŠ₯ε‘Š.pdf").MimeType("application/pdf")) + + attachments := tc.ListWithFilter(`filename.contains("ζŠ₯ε‘Š")`) + require.Len(t, attachments, 1) +} + +// ============================================================================= +// Mime Type Field Tests +// Schema: mime_type (string, ==, !=) +// ============================================================================= + +func TestAttachmentFilterMimeTypeEquals(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("photo.jpeg").MimeType("image/jpeg")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf")) + + // Test: mime_type == "image/png" + attachments := tc.ListWithFilter(`mime_type == "image/png"`) + require.Len(t, attachments, 1) + require.Equal(t, "image/png", attachments[0].Type) + + // Test: mime_type == "application/pdf" + attachments = tc.ListWithFilter(`mime_type == "application/pdf"`) + require.Len(t, attachments, 1) + require.Equal(t, "application/pdf", attachments[0].Type) +} + +func TestAttachmentFilterMimeTypeNotEquals(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf")) + + attachments := tc.ListWithFilter(`mime_type != "image/png"`) + require.Len(t, attachments, 1) + require.Equal(t, "application/pdf", attachments[0].Type) +} + +func TestAttachmentFilterMimeTypeInList(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("photo.jpeg").MimeType("image/jpeg")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf")) + + // Test: mime_type in ["image/png", "image/jpeg"] - matches images + attachments := tc.ListWithFilter(`mime_type in ["image/png", "image/jpeg"]`) + require.Len(t, attachments, 2) + + // Test: mime_type in ["video/mp4"] - no matches + attachments = tc.ListWithFilter(`mime_type in ["video/mp4"]`) + require.Len(t, attachments, 0) +} + +// ============================================================================= +// Create Time Field Tests +// Schema: create_time (timestamp, all comparison operators) +// Functions: now(), arithmetic (+, -, *) +// ============================================================================= + +func TestAttachmentFilterCreateTimeComparison(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + now := time.Now().Unix() + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png")) + + // Test: create_time < future (should match) + attachments := tc.ListWithFilter(`create_time < ` + formatInt64(now+3600)) + require.Len(t, attachments, 1) + + // Test: create_time > past (should match) + attachments = tc.ListWithFilter(`create_time > ` + formatInt64(now-3600)) + require.Len(t, attachments, 1) + + // Test: create_time > future (should not match) + attachments = tc.ListWithFilter(`create_time > ` + formatInt64(now+3600)) + require.Len(t, attachments, 0) +} + +func TestAttachmentFilterCreateTimeWithNow(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png")) + + // Test: create_time < now() + 5 (buffer for container clock drift) + attachments := tc.ListWithFilter(`create_time < now() + 5`) + require.Len(t, attachments, 1) + + // Test: create_time > now() + 5 (should not match) + attachments = tc.ListWithFilter(`create_time > now() + 5`) + require.Len(t, attachments, 0) +} + +func TestAttachmentFilterCreateTimeArithmetic(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png")) + + // Test: create_time >= now() - 3600 (attachments created in last hour) + attachments := tc.ListWithFilter(`create_time >= now() - 3600`) + require.Len(t, attachments, 1) + + // Test: create_time < now() - 86400 (attachments older than 1 day - should be empty) + attachments = tc.ListWithFilter(`create_time < now() - 86400`) + require.Len(t, attachments, 0) + + // Test: Multiplication - create_time >= now() - 60 * 60 + attachments = tc.ListWithFilter(`create_time >= now() - 60 * 60`) + require.Len(t, attachments, 1) +} + +func TestAttachmentFilterAllComparisonOperators(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png")) + + // Test: < (less than) + attachments := tc.ListWithFilter(`create_time < now() + 3600`) + require.Len(t, attachments, 1) + + // Test: <= (less than or equal) with buffer for clock drift + attachments = tc.ListWithFilter(`create_time < now() + 5`) + require.Len(t, attachments, 1) + + // Test: > (greater than) + attachments = tc.ListWithFilter(`create_time > now() - 3600`) + require.Len(t, attachments, 1) + + // Test: >= (greater than or equal) + attachments = tc.ListWithFilter(`create_time >= now() - 60`) + require.Len(t, attachments, 1) +} + +// ============================================================================= +// Memo ID Field Tests +// Schema: memo_id (int, ==, !=) +// ============================================================================= + +func TestAttachmentFilterMemoIdEquals(t *testing.T) { + tc := NewAttachmentFilterTestContextWithUser(t) + defer tc.Close() + + memo1 := tc.CreateMemo("memo-1", "Memo 1") + memo2 := tc.CreateMemo("memo-2", "Memo 2") + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("memo1_attachment.png").MimeType("image/png").MemoID(&memo1.ID)) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("memo2_attachment.png").MimeType("image/png").MemoID(&memo2.ID)) + + attachments := tc.ListWithFilter(`memo_id == ` + formatInt32(memo1.ID)) + require.Len(t, attachments, 1) + require.Equal(t, &memo1.ID, attachments[0].MemoID) +} + +func TestAttachmentFilterMemoIdNotEquals(t *testing.T) { + tc := NewAttachmentFilterTestContextWithUser(t) + defer tc.Close() + + memo1 := tc.CreateMemo("memo-1", "Memo 1") + memo2 := tc.CreateMemo("memo-2", "Memo 2") + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("memo1_attachment.png").MimeType("image/png").MemoID(&memo1.ID)) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("memo2_attachment.png").MimeType("image/png").MemoID(&memo2.ID)) + + attachments := tc.ListWithFilter(`memo_id != ` + formatInt32(memo1.ID)) + require.Len(t, attachments, 1) + require.Equal(t, &memo2.ID, attachments[0].MemoID) +} + +// ============================================================================= +// Logical Operator Tests +// Operators: && (AND), || (OR), ! (NOT) +// ============================================================================= + +func TestAttachmentFilterLogicalAnd(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("photo.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.pdf").MimeType("application/pdf")) + + attachments := tc.ListWithFilter(`mime_type == "image/png" && filename.contains("image")`) + require.Len(t, attachments, 1) + require.Equal(t, "image.png", attachments[0].Filename) +} + +func TestAttachmentFilterLogicalOr(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("video.mp4").MimeType("video/mp4")) + + attachments := tc.ListWithFilter(`mime_type == "image/png" || mime_type == "application/pdf"`) + require.Len(t, attachments, 2) +} + +func TestAttachmentFilterLogicalNot(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("image.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("document.pdf").MimeType("application/pdf")) + + attachments := tc.ListWithFilter(`!(mime_type == "image/png")`) + require.Len(t, attachments, 1) + require.Equal(t, "application/pdf", attachments[0].Type) +} + +func TestAttachmentFilterComplexLogical(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.pdf").MimeType("application/pdf")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("other.png").MimeType("image/png")) + + attachments := tc.ListWithFilter(`(mime_type == "image/png" || mime_type == "application/pdf") && filename.contains("report")`) + require.Len(t, attachments, 2) +} + +// ============================================================================= +// Multiple Filters Tests +// ============================================================================= + +func TestAttachmentFilterMultipleFilters(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("other.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("report.pdf").MimeType("application/pdf")) + + // Test: Multiple filters (applied as AND) + attachments := tc.ListWithFilters(`filename.contains("report")`, `mime_type == "image/png"`) + require.Len(t, attachments, 1) + require.Contains(t, attachments[0].Filename, "report") + require.Equal(t, "image/png", attachments[0].Type) +} + +// ============================================================================= +// Edge Cases +// ============================================================================= + +func TestAttachmentFilterNoMatches(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png")) + + attachments := tc.ListWithFilter(`filename.contains("nonexistent12345")`) + require.Len(t, attachments, 0) +} + +func TestAttachmentFilterNullMemoId(t *testing.T) { + tc := NewAttachmentFilterTestContextWithUser(t) + defer tc.Close() + + memo := tc.CreateMemo("memo-1", "Memo 1") + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("with_memo.png").MimeType("image/png").MemoID(&memo.ID)) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("no_memo.png").MimeType("image/png")) + + attachments := tc.ListWithFilter(`memo_id == ` + formatInt32(memo.ID)) + require.Len(t, attachments, 1) + require.Equal(t, "with_memo.png", attachments[0].Filename) +} + +func TestAttachmentFilterEmptyFilename(t *testing.T) { + tc := NewAttachmentFilterTestContext(t) + defer tc.Close() + + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("test.png").MimeType("image/png")) + tc.CreateAttachment(NewAttachmentBuilder(tc.CreatorID).Filename("other.pdf").MimeType("application/pdf")) + + // Test: filename.contains("") - should match all + attachments := tc.ListWithFilter(`filename.contains("")`) + require.Len(t, attachments, 2) +} diff --git a/store/test/attachment_test.go b/store/test/attachment_test.go index 68e080636..1886f75d5 100644 --- a/store/test/attachment_test.go +++ b/store/test/attachment_test.go @@ -2,6 +2,7 @@ package test import ( "context" + "fmt" "testing" "github.com/lithammer/shortuuid/v4" @@ -120,3 +121,121 @@ func TestAttachmentStoreWithFilter(t *testing.T) { ts.Close() } + +func TestAttachmentUpdate(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + attachment, err := ts.CreateAttachment(ctx, &store.Attachment{ + UID: shortuuid.New(), + CreatorID: 101, + Filename: "original.png", + Blob: []byte("test"), + Type: "image/png", + Size: 1000, + }) + require.NoError(t, err) + + // Update filename + newFilename := "updated.png" + err = ts.UpdateAttachment(ctx, &store.UpdateAttachment{ + ID: attachment.ID, + Filename: &newFilename, + }) + require.NoError(t, err) + + // Verify update + found, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &attachment.ID}) + require.NoError(t, err) + require.Equal(t, newFilename, found.Filename) + + ts.Close() +} + +func TestAttachmentGetByUID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + uid := shortuuid.New() + _, err := ts.CreateAttachment(ctx, &store.Attachment{ + UID: uid, + CreatorID: 101, + Filename: "test.png", + Blob: []byte("test"), + Type: "image/png", + Size: 1000, + }) + require.NoError(t, err) + + // Get by UID + found, err := ts.GetAttachment(ctx, &store.FindAttachment{UID: &uid}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, uid, found.UID) + + // Get non-existent UID + nonExistentUID := "non-existent-uid" + notFound, err := ts.GetAttachment(ctx, &store.FindAttachment{UID: &nonExistentUID}) + require.NoError(t, err) + require.Nil(t, notFound) + + ts.Close() +} + +func TestAttachmentListWithPagination(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create 5 attachments + for i := 0; i < 5; i++ { + _, err := ts.CreateAttachment(ctx, &store.Attachment{ + UID: shortuuid.New(), + CreatorID: 101, + Filename: fmt.Sprintf("test%d.png", i), + Blob: []byte("test"), + Type: "image/png", + Size: int64(1000 + i), + }) + require.NoError(t, err) + } + + // Test limit + limit := 3 + attachments, err := ts.ListAttachments(ctx, &store.FindAttachment{ + CreatorID: &[]int32{101}[0], + Limit: &limit, + }) + require.NoError(t, err) + require.Equal(t, 3, len(attachments)) + + // Test offset + offset := 2 + offsetAttachments, err := ts.ListAttachments(ctx, &store.FindAttachment{ + CreatorID: &[]int32{101}[0], + Limit: &limit, + Offset: &offset, + }) + require.NoError(t, err) + require.Equal(t, 3, len(offsetAttachments)) + + ts.Close() +} + +func TestAttachmentInvalidUID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create with invalid UID (contains spaces) + _, err := ts.CreateAttachment(ctx, &store.Attachment{ + UID: "invalid uid with spaces", + CreatorID: 101, + Filename: "test.png", + Blob: []byte("test"), + Type: "image/png", + Size: 1000, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uid") + + ts.Close() +} diff --git a/store/test/containers.go b/store/test/containers.go new file mode 100644 index 000000000..bd65ea40e --- /dev/null +++ b/store/test/containers.go @@ -0,0 +1,192 @@ +package test + +import ( + "context" + "database/sql" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mysql" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + // Database drivers for connection verification. + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" +) + +const ( + testUser = "root" + testPassword = "test" +) + +var ( + mysqlContainer *mysql.MySQLContainer + postgresContainer *postgres.PostgresContainer + mysqlOnce sync.Once + postgresOnce sync.Once + mysqlBaseDSN string + postgresBaseDSN string + dbCounter atomic.Int64 +) + +// GetMySQLDSN starts a MySQL container (if not already running) and creates a fresh database for this test. +func GetMySQLDSN(t *testing.T) string { + ctx := context.Background() + + mysqlOnce.Do(func() { + container, err := mysql.Run(ctx, + "mysql:8", + mysql.WithDatabase("init_db"), + mysql.WithUsername("root"), + mysql.WithPassword(testPassword), + testcontainers.WithEnv(map[string]string{ + "MYSQL_ROOT_PASSWORD": testPassword, + }), + testcontainers.WithWaitStrategy( + wait.ForAll( + wait.ForLog("ready for connections").WithOccurrence(2), + wait.ForListeningPort("3306/tcp"), + ).WithDeadline(120*time.Second), + ), + ) + if err != nil { + t.Fatalf("failed to start MySQL container: %v", err) + } + mysqlContainer = container + + dsn, err := container.ConnectionString(ctx, "multiStatements=true") + if err != nil { + t.Fatalf("failed to get MySQL connection string: %v", err) + } + + if err := waitForDB("mysql", dsn, 30*time.Second); err != nil { + t.Fatalf("MySQL not ready for connections: %v", err) + } + + mysqlBaseDSN = dsn + }) + + if mysqlBaseDSN == "" { + t.Fatal("MySQL container failed to start in a previous test") + } + + // Create a fresh database for this test + dbName := fmt.Sprintf("memos_test_%d", dbCounter.Add(1)) + db, err := sql.Open("mysql", mysqlBaseDSN) + if err != nil { + t.Fatalf("failed to connect to MySQL: %v", err) + } + defer db.Close() + + if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE `%s`", dbName)); err != nil { + t.Fatalf("failed to create database %s: %v", dbName, err) + } + + // Return DSN pointing to the new database + return strings.Replace(mysqlBaseDSN, "/init_db?", "/"+dbName+"?", 1) +} + +// waitForDB polls the database until it's ready or timeout is reached. +func waitForDB(driver, dsn string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + var lastErr error + for { + select { + case <-ctx.Done(): + if lastErr != nil { + return errors.Errorf("timeout waiting for %s database: %v", driver, lastErr) + } + return errors.Errorf("timeout waiting for %s database to be ready", driver) + case <-ticker.C: + db, err := sql.Open(driver, dsn) + if err != nil { + lastErr = err + continue + } + err = db.PingContext(ctx) + db.Close() + if err == nil { + return nil + } + lastErr = err + } + } +} + +// GetPostgresDSN starts a PostgreSQL container (if not already running) and creates a fresh database for this test. +func GetPostgresDSN(t *testing.T) string { + ctx := context.Background() + + postgresOnce.Do(func() { + container, err := postgres.Run(ctx, + "postgres:18", + postgres.WithDatabase("init_db"), + postgres.WithUsername(testUser), + postgres.WithPassword(testPassword), + testcontainers.WithWaitStrategy( + wait.ForAll( + wait.ForLog("database system is ready to accept connections").WithOccurrence(2), + wait.ForListeningPort("5432/tcp"), + ).WithDeadline(120*time.Second), + ), + ) + if err != nil { + t.Fatalf("failed to start PostgreSQL container: %v", err) + } + postgresContainer = container + + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("failed to get PostgreSQL connection string: %v", err) + } + + if err := waitForDB("postgres", dsn, 30*time.Second); err != nil { + t.Fatalf("PostgreSQL not ready for connections: %v", err) + } + + postgresBaseDSN = dsn + }) + + if postgresBaseDSN == "" { + t.Fatal("PostgreSQL container failed to start in a previous test") + } + + // Create a fresh database for this test + dbName := fmt.Sprintf("memos_test_%d", dbCounter.Add(1)) + db, err := sql.Open("postgres", postgresBaseDSN) + if err != nil { + t.Fatalf("failed to connect to PostgreSQL: %v", err) + } + defer db.Close() + + if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE %s", dbName)); err != nil { + t.Fatalf("failed to create database %s: %v", dbName, err) + } + + // Return DSN pointing to the new database + return strings.Replace(postgresBaseDSN, "/init_db?", "/"+dbName+"?", 1) +} + +// TerminateContainers cleans up all running containers. +// This is typically called from TestMain. +func TerminateContainers() { + ctx := context.Background() + if mysqlContainer != nil { + _ = mysqlContainer.Terminate(ctx) + } + if postgresContainer != nil { + _ = postgresContainer.Terminate(ctx) + } +} diff --git a/store/test/filter_helpers_test.go b/store/test/filter_helpers_test.go new file mode 100644 index 000000000..b7dda7db1 --- /dev/null +++ b/store/test/filter_helpers_test.go @@ -0,0 +1,290 @@ +package test + +import ( + "context" + "strconv" + "testing" + + "github.com/lithammer/shortuuid/v4" + "github.com/stretchr/testify/require" + + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/store" +) + +// ============================================================================= +// Formatting Helpers +// ============================================================================= + +func formatInt64(n int64) string { + return strconv.FormatInt(n, 10) +} + +func formatInt32(n int32) string { + return strconv.FormatInt(int64(n), 10) +} + +func formatInt(n int) string { + return strconv.Itoa(n) +} + +// ============================================================================= +// Pointer Helpers +// ============================================================================= + +func boolPtr(b bool) *bool { + return &b +} + +// ============================================================================= +// Test Fixture Builders +// ============================================================================= + +// MemoBuilder provides a fluent API for creating test memos. +type MemoBuilder struct { + memo *store.Memo +} + +// NewMemoBuilder creates a new memo builder with required fields. +func NewMemoBuilder(uid string, creatorID int32) *MemoBuilder { + return &MemoBuilder{ + memo: &store.Memo{ + UID: uid, + CreatorID: creatorID, + Visibility: store.Public, + }, + } +} + +func (b *MemoBuilder) Content(content string) *MemoBuilder { + b.memo.Content = content + return b +} + +func (b *MemoBuilder) Visibility(v store.Visibility) *MemoBuilder { + b.memo.Visibility = v + return b +} + +func (b *MemoBuilder) Tags(tags ...string) *MemoBuilder { + if b.memo.Payload == nil { + b.memo.Payload = &storepb.MemoPayload{} + } + b.memo.Payload.Tags = tags + return b +} + +func (b *MemoBuilder) Property(fn func(*storepb.MemoPayload_Property)) *MemoBuilder { + if b.memo.Payload == nil { + b.memo.Payload = &storepb.MemoPayload{} + } + if b.memo.Payload.Property == nil { + b.memo.Payload.Property = &storepb.MemoPayload_Property{} + } + fn(b.memo.Payload.Property) + return b +} + +func (b *MemoBuilder) Build() *store.Memo { + return b.memo +} + +// AttachmentBuilder provides a fluent API for creating test attachments. +type AttachmentBuilder struct { + attachment *store.Attachment +} + +// NewAttachmentBuilder creates a new attachment builder with required fields. +func NewAttachmentBuilder(creatorID int32) *AttachmentBuilder { + return &AttachmentBuilder{ + attachment: &store.Attachment{ + UID: shortuuid.New(), + CreatorID: creatorID, + Blob: []byte("test"), + Size: 1000, + }, + } +} + +func (b *AttachmentBuilder) Filename(filename string) *AttachmentBuilder { + b.attachment.Filename = filename + return b +} + +func (b *AttachmentBuilder) MimeType(mimeType string) *AttachmentBuilder { + b.attachment.Type = mimeType + return b +} + +func (b *AttachmentBuilder) MemoID(memoID *int32) *AttachmentBuilder { + b.attachment.MemoID = memoID + return b +} + +func (b *AttachmentBuilder) Size(size int64) *AttachmentBuilder { + b.attachment.Size = size + return b +} + +func (b *AttachmentBuilder) Build() *store.Attachment { + return b.attachment +} + +// ============================================================================= +// Test Context Helpers +// ============================================================================= + +// MemoFilterTestContext holds common test dependencies for memo filter tests. +type MemoFilterTestContext struct { + Ctx context.Context + T *testing.T + Store *store.Store + User *store.User +} + +// NewMemoFilterTestContext creates a new test context with store and user. +func NewMemoFilterTestContext(t *testing.T) *MemoFilterTestContext { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + return &MemoFilterTestContext{ + Ctx: ctx, + T: t, + Store: ts, + User: user, + } +} + +// CreateMemo creates a memo using the builder pattern. +func (tc *MemoFilterTestContext) CreateMemo(b *MemoBuilder) *store.Memo { + memo, err := tc.Store.CreateMemo(tc.Ctx, b.Build()) + require.NoError(tc.T, err) + return memo +} + +// PinMemo pins a memo by ID. +func (tc *MemoFilterTestContext) PinMemo(memoID int32) { + err := tc.Store.UpdateMemo(tc.Ctx, &store.UpdateMemo{ + ID: memoID, + Pinned: boolPtr(true), + }) + require.NoError(tc.T, err) +} + +// ListWithFilter lists memos with the given filter and returns the count. +func (tc *MemoFilterTestContext) ListWithFilter(filter string) []*store.Memo { + memos, err := tc.Store.ListMemos(tc.Ctx, &store.FindMemo{ + Filters: []string{filter}, + }) + require.NoError(tc.T, err) + return memos +} + +// ListWithFilters lists memos with multiple filters and returns the count. +func (tc *MemoFilterTestContext) ListWithFilters(filters ...string) []*store.Memo { + memos, err := tc.Store.ListMemos(tc.Ctx, &store.FindMemo{ + Filters: filters, + }) + require.NoError(tc.T, err) + return memos +} + +// Close closes the test store. +func (tc *MemoFilterTestContext) Close() { + tc.Store.Close() +} + +// AttachmentFilterTestContext holds common test dependencies for attachment filter tests. +type AttachmentFilterTestContext struct { + Ctx context.Context + T *testing.T + Store *store.Store + User *store.User + CreatorID int32 +} + +// NewAttachmentFilterTestContext creates a new test context for attachments. +func NewAttachmentFilterTestContext(t *testing.T) *AttachmentFilterTestContext { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + return &AttachmentFilterTestContext{ + Ctx: ctx, + T: t, + Store: ts, + CreatorID: 101, + } +} + +// NewAttachmentFilterTestContextWithUser creates a new test context with a user. +func NewAttachmentFilterTestContextWithUser(t *testing.T) *AttachmentFilterTestContext { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + return &AttachmentFilterTestContext{ + Ctx: ctx, + T: t, + Store: ts, + User: user, + CreatorID: user.ID, + } +} + +// CreateAttachment creates an attachment using the builder pattern. +func (tc *AttachmentFilterTestContext) CreateAttachment(b *AttachmentBuilder) *store.Attachment { + attachment, err := tc.Store.CreateAttachment(tc.Ctx, b.Build()) + require.NoError(tc.T, err) + return attachment +} + +// CreateMemo creates a memo (for attachment tests that need memos). +func (tc *AttachmentFilterTestContext) CreateMemo(uid, content string) *store.Memo { + memo, err := tc.Store.CreateMemo(tc.Ctx, &store.Memo{ + UID: uid, + CreatorID: tc.CreatorID, + Content: content, + Visibility: store.Public, + }) + require.NoError(tc.T, err) + return memo +} + +// ListWithFilter lists attachments with the given filter. +func (tc *AttachmentFilterTestContext) ListWithFilter(filter string) []*store.Attachment { + attachments, err := tc.Store.ListAttachments(tc.Ctx, &store.FindAttachment{ + CreatorID: &tc.CreatorID, + Filters: []string{filter}, + }) + require.NoError(tc.T, err) + return attachments +} + +// ListWithFilters lists attachments with multiple filters. +func (tc *AttachmentFilterTestContext) ListWithFilters(filters ...string) []*store.Attachment { + attachments, err := tc.Store.ListAttachments(tc.Ctx, &store.FindAttachment{ + CreatorID: &tc.CreatorID, + Filters: filters, + }) + require.NoError(tc.T, err) + return attachments +} + +// Close closes the test store. +func (tc *AttachmentFilterTestContext) Close() { + tc.Store.Close() +} + +// ============================================================================= +// Filter Test Case Definition +// ============================================================================= + +// FilterTestCase defines a single filter test case for table-driven tests. +type FilterTestCase struct { + Name string + Filter string + ExpectedCount int +} diff --git a/store/test/instance_setting_test.go b/store/test/instance_setting_test.go index 909ff46d8..9ab072753 100644 --- a/store/test/instance_setting_test.go +++ b/store/test/instance_setting_test.go @@ -29,3 +29,220 @@ func TestInstanceSettingV1Store(t *testing.T) { require.Equal(t, instanceSetting, setting) ts.Close() } + +func TestInstanceSettingGetNonExistent(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Get non-existent setting + setting, err := ts.GetInstanceSetting(ctx, &store.FindInstanceSetting{ + Name: storepb.InstanceSettingKey_STORAGE.String(), + }) + require.NoError(t, err) + require.Nil(t, setting) + + ts.Close() +} + +func TestInstanceSettingUpsertUpdate(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create setting + _, err := ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_GENERAL, + Value: &storepb.InstanceSetting_GeneralSetting{ + GeneralSetting: &storepb.InstanceGeneralSetting{ + AdditionalScript: "console.log('v1')", + }, + }, + }) + require.NoError(t, err) + + // Update setting + _, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_GENERAL, + Value: &storepb.InstanceSetting_GeneralSetting{ + GeneralSetting: &storepb.InstanceGeneralSetting{ + AdditionalScript: "console.log('v2')", + }, + }, + }) + require.NoError(t, err) + + // Verify update + setting, err := ts.GetInstanceSetting(ctx, &store.FindInstanceSetting{ + Name: storepb.InstanceSettingKey_GENERAL.String(), + }) + require.NoError(t, err) + require.Equal(t, "console.log('v2')", setting.GetGeneralSetting().AdditionalScript) + + // Verify only one setting exists + list, err := ts.ListInstanceSettings(ctx, &store.FindInstanceSetting{ + Name: storepb.InstanceSettingKey_GENERAL.String(), + }) + require.NoError(t, err) + require.Equal(t, 1, len(list)) + + ts.Close() +} + +func TestInstanceSettingBasicSetting(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Get default basic setting (should return empty defaults) + basicSetting, err := ts.GetInstanceBasicSetting(ctx) + require.NoError(t, err) + require.NotNil(t, basicSetting) + + // Set basic setting + _, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_BASIC, + Value: &storepb.InstanceSetting_BasicSetting{ + BasicSetting: &storepb.InstanceBasicSetting{ + SecretKey: "my-secret-key", + }, + }, + }) + require.NoError(t, err) + + // Verify + basicSetting, err = ts.GetInstanceBasicSetting(ctx) + require.NoError(t, err) + require.Equal(t, "my-secret-key", basicSetting.SecretKey) + + ts.Close() +} + +func TestInstanceSettingGeneralSetting(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Get default general setting + generalSetting, err := ts.GetInstanceGeneralSetting(ctx) + require.NoError(t, err) + require.NotNil(t, generalSetting) + + // Set general setting + _, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_GENERAL, + Value: &storepb.InstanceSetting_GeneralSetting{ + GeneralSetting: &storepb.InstanceGeneralSetting{ + AdditionalScript: "console.log('test')", + AdditionalStyle: "body { color: red; }", + }, + }, + }) + require.NoError(t, err) + + // Verify + generalSetting, err = ts.GetInstanceGeneralSetting(ctx) + require.NoError(t, err) + require.Equal(t, "console.log('test')", generalSetting.AdditionalScript) + require.Equal(t, "body { color: red; }", generalSetting.AdditionalStyle) + + ts.Close() +} + +func TestInstanceSettingMemoRelatedSetting(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Get default memo related setting (should have defaults) + memoSetting, err := ts.GetInstanceMemoRelatedSetting(ctx) + require.NoError(t, err) + require.NotNil(t, memoSetting) + require.GreaterOrEqual(t, memoSetting.ContentLengthLimit, int32(store.DefaultContentLengthLimit)) + require.NotEmpty(t, memoSetting.Reactions) + + // Set custom memo related setting + customReactions := []string{"πŸ‘", "πŸ‘Ž", "πŸš€"} + _, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_MEMO_RELATED, + Value: &storepb.InstanceSetting_MemoRelatedSetting{ + MemoRelatedSetting: &storepb.InstanceMemoRelatedSetting{ + ContentLengthLimit: 16384, + Reactions: customReactions, + }, + }, + }) + require.NoError(t, err) + + // Verify + memoSetting, err = ts.GetInstanceMemoRelatedSetting(ctx) + require.NoError(t, err) + require.Equal(t, int32(16384), memoSetting.ContentLengthLimit) + require.Equal(t, customReactions, memoSetting.Reactions) + + ts.Close() +} + +func TestInstanceSettingStorageSetting(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Get default storage setting (should have defaults) + storageSetting, err := ts.GetInstanceStorageSetting(ctx) + require.NoError(t, err) + require.NotNil(t, storageSetting) + require.Equal(t, storepb.InstanceStorageSetting_DATABASE, storageSetting.StorageType) + require.Equal(t, int64(30), storageSetting.UploadSizeLimitMb) + require.Equal(t, "assets/{timestamp}_{filename}", storageSetting.FilepathTemplate) + + // Set custom storage setting + _, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_STORAGE, + Value: &storepb.InstanceSetting_StorageSetting{ + StorageSetting: &storepb.InstanceStorageSetting{ + StorageType: storepb.InstanceStorageSetting_LOCAL, + UploadSizeLimitMb: 100, + FilepathTemplate: "uploads/{date}/{filename}", + }, + }, + }) + require.NoError(t, err) + + // Verify + storageSetting, err = ts.GetInstanceStorageSetting(ctx) + require.NoError(t, err) + require.Equal(t, storepb.InstanceStorageSetting_LOCAL, storageSetting.StorageType) + require.Equal(t, int64(100), storageSetting.UploadSizeLimitMb) + require.Equal(t, "uploads/{date}/{filename}", storageSetting.FilepathTemplate) + + ts.Close() +} + +func TestInstanceSettingListAll(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Count initial settings + initialList, err := ts.ListInstanceSettings(ctx, &store.FindInstanceSetting{}) + require.NoError(t, err) + initialCount := len(initialList) + + // Create multiple settings + _, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_GENERAL, + Value: &storepb.InstanceSetting_GeneralSetting{ + GeneralSetting: &storepb.InstanceGeneralSetting{}, + }, + }) + require.NoError(t, err) + + _, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_STORAGE, + Value: &storepb.InstanceSetting_StorageSetting{ + StorageSetting: &storepb.InstanceStorageSetting{}, + }, + }) + require.NoError(t, err) + + // List all - should have 2 more than initial + list, err := ts.ListInstanceSettings(ctx, &store.FindInstanceSetting{}) + require.NoError(t, err) + require.Equal(t, initialCount+2, len(list)) + + ts.Close() +} diff --git a/store/test/main_test.go b/store/test/main_test.go new file mode 100644 index 000000000..97a632765 --- /dev/null +++ b/store/test/main_test.go @@ -0,0 +1,50 @@ +package test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +func TestMain(m *testing.M) { + // If DRIVER is set, run tests for that driver only + if os.Getenv("DRIVER") != "" { + defer TerminateContainers() + m.Run() + return + } + + // No DRIVER set - run tests for all drivers sequentially + runAllDrivers() +} + +func runAllDrivers() { + drivers := []string{"sqlite", "mysql", "postgres"} + _, currentFile, _, _ := runtime.Caller(0) + projectRoot := filepath.Dir(filepath.Dir(filepath.Dir(currentFile))) + + var failed []string + for _, driver := range drivers { + fmt.Printf("\n==================== %s ====================\n\n", driver) + + cmd := exec.Command("go", "test", "-v", "-count=1", "./store/test/...") + cmd.Dir = projectRoot + cmd.Env = append(os.Environ(), "DRIVER="+driver) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + failed = append(failed, driver) + } + } + + fmt.Println() + if len(failed) > 0 { + fmt.Printf("FAIL: %v\n", failed) + panic("some drivers failed") + } + fmt.Println("PASS: all drivers") +} diff --git a/store/test/memo_filter_test.go b/store/test/memo_filter_test.go new file mode 100644 index 000000000..9f2520211 --- /dev/null +++ b/store/test/memo_filter_test.go @@ -0,0 +1,591 @@ +package test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/store" +) + +// ============================================================================= +// Content Field Tests +// Schema: content (string, supports contains) +// ============================================================================= + +func TestMemoFilterContentContains(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + // Create memos with different content + tc.CreateMemo(NewMemoBuilder("memo-hello", tc.User.ID).Content("Hello world")) + tc.CreateMemo(NewMemoBuilder("memo-goodbye", tc.User.ID).Content("Goodbye world")) + tc.CreateMemo(NewMemoBuilder("memo-test", tc.User.ID).Content("Testing content")) + + // Test: content.contains("Hello") - single match + memos := tc.ListWithFilter(`content.contains("Hello")`) + require.Len(t, memos, 1) + require.Contains(t, memos[0].Content, "Hello") + + // Test: content.contains("world") - multiple matches + memos = tc.ListWithFilter(`content.contains("world")`) + require.Len(t, memos, 2) + + // Test: content.contains("nonexistent") - no matches + memos = tc.ListWithFilter(`content.contains("nonexistent")`) + require.Len(t, memos, 0) +} + +func TestMemoFilterContentSpecialCharacters(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-special", tc.User.ID).Content("Special chars: @#$%^&*()")) + + memos := tc.ListWithFilter(`content.contains("@#$%")`) + require.Len(t, memos, 1) +} + +func TestMemoFilterContentUnicode(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-unicode", tc.User.ID).Content("Unicode test: δ½ ε₯½δΈ–η•Œ 🌍")) + + memos := tc.ListWithFilter(`content.contains("δ½ ε₯½")`) + require.Len(t, memos, 1) +} + +// ============================================================================= +// Visibility Field Tests +// Schema: visibility (string, ==, !=) +// ============================================================================= + +func TestMemoFilterVisibilityEquals(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-public", tc.User.ID).Content("Public memo").Visibility(store.Public)) + tc.CreateMemo(NewMemoBuilder("memo-private", tc.User.ID).Content("Private memo").Visibility(store.Private)) + tc.CreateMemo(NewMemoBuilder("memo-protected", tc.User.ID).Content("Protected memo").Visibility(store.Protected)) + + // Test: visibility == "PUBLIC" + memos := tc.ListWithFilter(`visibility == "PUBLIC"`) + require.Len(t, memos, 1) + require.Equal(t, store.Public, memos[0].Visibility) + + // Test: visibility == "PRIVATE" + memos = tc.ListWithFilter(`visibility == "PRIVATE"`) + require.Len(t, memos, 1) + require.Equal(t, store.Private, memos[0].Visibility) +} + +func TestMemoFilterVisibilityNotEquals(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-public", tc.User.ID).Content("Public memo").Visibility(store.Public)) + tc.CreateMemo(NewMemoBuilder("memo-private", tc.User.ID).Content("Private memo").Visibility(store.Private)) + + memos := tc.ListWithFilter(`visibility != "PUBLIC"`) + require.Len(t, memos, 1) + require.Equal(t, store.Private, memos[0].Visibility) +} + +func TestMemoFilterVisibilityInList(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-pub", tc.User.ID).Visibility(store.Public)) + tc.CreateMemo(NewMemoBuilder("memo-priv", tc.User.ID).Visibility(store.Private)) + tc.CreateMemo(NewMemoBuilder("memo-prot", tc.User.ID).Visibility(store.Protected)) + + memos := tc.ListWithFilter(`visibility in ["PUBLIC", "PRIVATE"]`) + require.Len(t, memos, 2) +} + +// ============================================================================= +// Pinned Field Tests +// Schema: pinned (bool column, ==, !=, predicate) +// ============================================================================= + +func TestMemoFilterPinnedEquals(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned", tc.User.ID).Content("Pinned memo")) + tc.PinMemo(pinnedMemo.ID) + tc.CreateMemo(NewMemoBuilder("memo-unpinned", tc.User.ID).Content("Unpinned memo")) + + // Test: pinned == true + memos := tc.ListWithFilter(`pinned == true`) + require.Len(t, memos, 1) + require.True(t, memos[0].Pinned) + + // Test: pinned == false + memos = tc.ListWithFilter(`pinned == false`) + require.Len(t, memos, 1) + require.False(t, memos[0].Pinned) +} + +func TestMemoFilterPinnedPredicate(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned", tc.User.ID).Content("Pinned memo")) + tc.PinMemo(pinnedMemo.ID) + tc.CreateMemo(NewMemoBuilder("memo-unpinned", tc.User.ID).Content("Unpinned memo")) + + memos := tc.ListWithFilter(`pinned`) + require.Len(t, memos, 1) + require.True(t, memos[0].Pinned) +} + +// ============================================================================= +// Creator ID Field Tests +// Schema: creator_id (int, ==, !=) +// ============================================================================= + +func TestMemoFilterCreatorIdEquals(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + user2, err := tc.Store.CreateUser(tc.Ctx, &store.User{ + Username: "user2", + Role: store.RoleUser, + Email: "user2@example.com", + Nickname: "User 2", + }) + require.NoError(t, err) + + tc.CreateMemo(NewMemoBuilder("memo-user1", tc.User.ID).Content("User 1 memo")) + tc.CreateMemo(NewMemoBuilder("memo-user2", user2.ID).Content("User 2 memo")) + + memos := tc.ListWithFilter(`creator_id == ` + formatInt(int(tc.User.ID))) + require.Len(t, memos, 1) + require.Equal(t, tc.User.ID, memos[0].CreatorID) +} + +func TestMemoFilterCreatorIdNotEquals(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + user2, err := tc.Store.CreateUser(tc.Ctx, &store.User{ + Username: "user2", + Role: store.RoleUser, + Email: "user2@example.com", + Nickname: "User 2", + }) + require.NoError(t, err) + + tc.CreateMemo(NewMemoBuilder("memo-user1", tc.User.ID).Content("User 1 memo")) + tc.CreateMemo(NewMemoBuilder("memo-user2", user2.ID).Content("User 2 memo")) + + memos := tc.ListWithFilter(`creator_id != ` + formatInt(int(tc.User.ID))) + require.Len(t, memos, 1) + require.Equal(t, user2.ID, memos[0].CreatorID) +} + +// ============================================================================= +// Tags Field Tests +// Schema: tags (JSON list), tag (virtual alias) +// Operators: tag in [...], "value" in tags +// ============================================================================= + +func TestMemoFilterTagInList(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-work", tc.User.ID).Content("Work memo").Tags("work", "important")) + tc.CreateMemo(NewMemoBuilder("memo-personal", tc.User.ID).Content("Personal memo").Tags("personal", "fun")) + tc.CreateMemo(NewMemoBuilder("memo-no-tags", tc.User.ID).Content("No tags")) + + // Test: tag in ["work"] + memos := tc.ListWithFilter(`tag in ["work"]`) + require.Len(t, memos, 1) + require.Contains(t, memos[0].Payload.Tags, "work") + + // Test: tag in ["work", "personal"] + memos = tc.ListWithFilter(`tag in ["work", "personal"]`) + require.Len(t, memos, 2) +} + +func TestMemoFilterElementInTags(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-tagged", tc.User.ID).Content("Tagged memo").Tags("project", "todo")) + tc.CreateMemo(NewMemoBuilder("memo-untagged", tc.User.ID).Content("Untagged memo")) + + // Test: "project" in tags + memos := tc.ListWithFilter(`"project" in tags`) + require.Len(t, memos, 1) + + // Test: "nonexistent" in tags + memos = tc.ListWithFilter(`"nonexistent" in tags`) + require.Len(t, memos, 0) +} + +func TestMemoFilterHierarchicalTags(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-book", tc.User.ID).Content("Book memo").Tags("book")) + tc.CreateMemo(NewMemoBuilder("memo-book-fiction", tc.User.ID).Content("Fiction book memo").Tags("book/fiction")) + + // Test: tag in ["book"] should match both (hierarchical matching) + memos := tc.ListWithFilter(`tag in ["book"]`) + require.Len(t, memos, 2) +} + +func TestMemoFilterEmptyTags(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-empty-tags", tc.User.ID).Content("Empty tags").Tags()) + + memos := tc.ListWithFilter(`tag in ["anything"]`) + require.Len(t, memos, 0) +} + +// ============================================================================= +// JSON Bool Field Tests +// Schema: has_task_list, has_link, has_code, has_incomplete_tasks +// Operators: ==, !=, predicate +// ============================================================================= + +func TestMemoFilterHasTaskList(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-with-tasks", tc.User.ID). + Content("- [ ] Task 1\n- [x] Task 2"). + Property(func(p *storepb.MemoPayload_Property) { p.HasTaskList = true })) + tc.CreateMemo(NewMemoBuilder("memo-no-tasks", tc.User.ID).Content("No tasks here")) + + // Test: has_task_list (predicate) + memos := tc.ListWithFilter(`has_task_list`) + require.Len(t, memos, 1) + require.True(t, memos[0].Payload.Property.HasTaskList) + + // Test: has_task_list == true + memos = tc.ListWithFilter(`has_task_list == true`) + require.Len(t, memos, 1) + + // Note: has_task_list == false is not tested because JSON boolean fields + // with false value may not be queryable when the field is not present in JSON +} + +func TestMemoFilterHasLink(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-with-link", tc.User.ID). + Content("Check out https://example.com"). + Property(func(p *storepb.MemoPayload_Property) { p.HasLink = true })) + tc.CreateMemo(NewMemoBuilder("memo-no-link", tc.User.ID).Content("No links")) + + memos := tc.ListWithFilter(`has_link`) + require.Len(t, memos, 1) + require.True(t, memos[0].Payload.Property.HasLink) +} + +func TestMemoFilterHasCode(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-with-code", tc.User.ID). + Content("```go\nfmt.Println(\"Hello\")\n```"). + Property(func(p *storepb.MemoPayload_Property) { p.HasCode = true })) + tc.CreateMemo(NewMemoBuilder("memo-no-code", tc.User.ID).Content("No code")) + + memos := tc.ListWithFilter(`has_code`) + require.Len(t, memos, 1) + require.True(t, memos[0].Payload.Property.HasCode) +} + +func TestMemoFilterHasIncompleteTasks(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-incomplete", tc.User.ID). + Content("- [ ] Incomplete task"). + Property(func(p *storepb.MemoPayload_Property) { + p.HasTaskList = true + p.HasIncompleteTasks = true + })) + tc.CreateMemo(NewMemoBuilder("memo-complete", tc.User.ID). + Content("- [x] Complete task"). + Property(func(p *storepb.MemoPayload_Property) { + p.HasTaskList = true + p.HasIncompleteTasks = false + })) + + memos := tc.ListWithFilter(`has_incomplete_tasks`) + require.Len(t, memos, 1) + require.True(t, memos[0].Payload.Property.HasIncompleteTasks) +} + +func TestMemoFilterCombinedJSONBool(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + // Memo with all properties + tc.CreateMemo(NewMemoBuilder("memo-all-props", tc.User.ID). + Content("All properties"). + Property(func(p *storepb.MemoPayload_Property) { + p.HasLink = true + p.HasTaskList = true + p.HasCode = true + p.HasIncompleteTasks = true + })) + + // Memo with only link + tc.CreateMemo(NewMemoBuilder("memo-only-link", tc.User.ID). + Content("Only link"). + Property(func(p *storepb.MemoPayload_Property) { p.HasLink = true })) + + // Test: has_link && has_code + memos := tc.ListWithFilter(`has_link && has_code`) + require.Len(t, memos, 1) + + // Test: has_task_list && has_incomplete_tasks + memos = tc.ListWithFilter(`has_task_list && has_incomplete_tasks`) + require.Len(t, memos, 1) + + // Test: has_link || has_code + memos = tc.ListWithFilter(`has_link || has_code`) + require.Len(t, memos, 2) +} + +// ============================================================================= +// Timestamp Field Tests +// Schema: created_ts, updated_ts (timestamp, all comparison operators) +// Functions: now(), arithmetic (+, -, *) +// ============================================================================= + +func TestMemoFilterCreatedTsComparison(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + now := time.Now().Unix() + tc.CreateMemo(NewMemoBuilder("memo-ts", tc.User.ID).Content("Timestamp test")) + + // Test: created_ts < future (should match) + memos := tc.ListWithFilter(`created_ts < ` + formatInt64(now+3600)) + require.Len(t, memos, 1) + + // Test: created_ts > past (should match) + memos = tc.ListWithFilter(`created_ts > ` + formatInt64(now-3600)) + require.Len(t, memos, 1) + + // Test: created_ts > future (should not match) + memos = tc.ListWithFilter(`created_ts > ` + formatInt64(now+3600)) + require.Len(t, memos, 0) +} + +func TestMemoFilterCreatedTsWithNow(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-ts-test", tc.User.ID).Content("Timestamp test")) + + // Test: created_ts < now() + 5 (buffer for container clock drift) + memos := tc.ListWithFilter(`created_ts < now() + 5`) + require.Len(t, memos, 1) + + // Test: created_ts > now() + 5 (should not match) + memos = tc.ListWithFilter(`created_ts > now() + 5`) + require.Len(t, memos, 0) +} + +func TestMemoFilterCreatedTsArithmetic(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-ts-arith", tc.User.ID).Content("Timestamp arithmetic test")) + + // Test: created_ts >= now() - 3600 (memos created in last hour) + memos := tc.ListWithFilter(`created_ts >= now() - 3600`) + require.Len(t, memos, 1) + + // Test: created_ts < now() - 86400 (memos older than 1 day - should be empty) + memos = tc.ListWithFilter(`created_ts < now() - 86400`) + require.Len(t, memos, 0) + + // Test: Multiplication - created_ts >= now() - 60 * 60 + memos = tc.ListWithFilter(`created_ts >= now() - 60 * 60`) + require.Len(t, memos, 1) +} + +func TestMemoFilterUpdatedTs(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + memo := tc.CreateMemo(NewMemoBuilder("memo-updated", tc.User.ID).Content("Will be updated")) + + // Update the memo + newContent := "Updated content" + err := tc.Store.UpdateMemo(tc.Ctx, &store.UpdateMemo{ + ID: memo.ID, + Content: &newContent, + }) + require.NoError(t, err) + + // Test: updated_ts >= now() - 60 (updated in last minute) + memos := tc.ListWithFilter(`updated_ts >= now() - 60`) + require.Len(t, memos, 1) + + // Test: updated_ts > now() + 3600 (should be empty) + memos = tc.ListWithFilter(`updated_ts > now() + 3600`) + require.Len(t, memos, 0) +} + +func TestMemoFilterAllComparisonOperators(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-ops", tc.User.ID).Content("Comparison operators test")) + + // Test: < (less than) + memos := tc.ListWithFilter(`created_ts < now() + 3600`) + require.Len(t, memos, 1) + + // Test: <= (less than or equal) with buffer for clock drift + memos = tc.ListWithFilter(`created_ts < now() + 5`) + require.Len(t, memos, 1) + + // Test: > (greater than) + memos = tc.ListWithFilter(`created_ts > now() - 3600`) + require.Len(t, memos, 1) + + // Test: >= (greater than or equal) + memos = tc.ListWithFilter(`created_ts >= now() - 60`) + require.Len(t, memos, 1) +} + +// ============================================================================= +// Logical Operator Tests +// Operators: && (AND), || (OR), ! (NOT) +// ============================================================================= + +func TestMemoFilterLogicalAnd(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned-public", tc.User.ID).Content("Pinned public")) + tc.PinMemo(pinnedMemo.ID) + tc.CreateMemo(NewMemoBuilder("memo-unpinned-public", tc.User.ID).Content("Unpinned public")) + + memos := tc.ListWithFilter(`pinned && visibility == "PUBLIC"`) + require.Len(t, memos, 1) + require.True(t, memos[0].Pinned) +} + +func TestMemoFilterLogicalOr(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-public", tc.User.ID).Visibility(store.Public)) + tc.CreateMemo(NewMemoBuilder("memo-private", tc.User.ID).Visibility(store.Private)) + tc.CreateMemo(NewMemoBuilder("memo-protected", tc.User.ID).Visibility(store.Protected)) + + memos := tc.ListWithFilter(`visibility == "PUBLIC" || visibility == "PRIVATE"`) + require.Len(t, memos, 2) +} + +func TestMemoFilterLogicalNot(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned", tc.User.ID).Content("Pinned")) + tc.PinMemo(pinnedMemo.ID) + tc.CreateMemo(NewMemoBuilder("memo-unpinned", tc.User.ID).Content("Unpinned")) + + memos := tc.ListWithFilter(`!pinned`) + require.Len(t, memos, 1) + require.False(t, memos[0].Pinned) +} + +func TestMemoFilterNegatedComparison(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-public", tc.User.ID).Visibility(store.Public)) + tc.CreateMemo(NewMemoBuilder("memo-private", tc.User.ID).Visibility(store.Private)) + + memos := tc.ListWithFilter(`!(visibility == "PUBLIC")`) + require.Len(t, memos, 1) + require.Equal(t, store.Private, memos[0].Visibility) +} + +func TestMemoFilterComplexLogical(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + // Create pinned public memo with tags + pinnedMemo := tc.CreateMemo(NewMemoBuilder("memo-pinned-tagged", tc.User.ID). + Content("Pinned and tagged").Tags("important")) + tc.PinMemo(pinnedMemo.ID) + + // Create unpinned memo with same tag + tc.CreateMemo(NewMemoBuilder("memo-unpinned-tagged", tc.User.ID). + Content("Unpinned but tagged").Tags("important")) + + // Create pinned memo without tag + pinned2 := tc.CreateMemo(NewMemoBuilder("memo-pinned-untagged", tc.User.ID).Content("Pinned but untagged")) + tc.PinMemo(pinned2.ID) + + // Test: pinned && tag in ["important"] + memos := tc.ListWithFilter(`pinned && tag in ["important"]`) + require.Len(t, memos, 1) + + // Test: (pinned || tag in ["important"]) && visibility == "PUBLIC" + memos = tc.ListWithFilter(`(pinned || tag in ["important"]) && visibility == "PUBLIC"`) + require.Len(t, memos, 3) +} + +// ============================================================================= +// Multiple Filters Tests +// ============================================================================= + +func TestMemoFilterMultipleFilters(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-public-hello", tc.User.ID).Content("Hello world").Visibility(store.Public)) + tc.CreateMemo(NewMemoBuilder("memo-private-hello", tc.User.ID).Content("Hello private").Visibility(store.Private)) + + // Test: Multiple filters (applied as AND) + memos := tc.ListWithFilters(`content.contains("Hello")`, `visibility == "PUBLIC"`) + require.Len(t, memos, 1) + require.Contains(t, memos[0].Content, "Hello") + require.Equal(t, store.Public, memos[0].Visibility) +} + +// ============================================================================= +// Edge Cases +// ============================================================================= + +func TestMemoFilterNullPayload(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-null-payload", tc.User.ID).Content("Null payload")) + + // Test: has_link should not crash and return no results + memos := tc.ListWithFilter(`has_link`) + require.Len(t, memos, 0) +} + +func TestMemoFilterNoMatches(t *testing.T) { + tc := NewMemoFilterTestContext(t) + defer tc.Close() + + tc.CreateMemo(NewMemoBuilder("memo-test", tc.User.ID).Content("Test content")) + + memos := tc.ListWithFilter(`content.contains("nonexistent12345")`) + require.Len(t, memos, 0) +} diff --git a/store/test/memo_relation_test.go b/store/test/memo_relation_test.go index feec0c28c..dd05134ef 100644 --- a/store/test/memo_relation_test.go +++ b/store/test/memo_relation_test.go @@ -60,3 +60,182 @@ func TestMemoRelationStore(t *testing.T) { require.NoError(t, err) ts.Close() } + +func TestMemoRelationListByMemoID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create main memo + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create related memos + relatedMemo1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-1", + CreatorID: user.ID, + Content: "related memo 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-2", + CreatorID: user.ID, + Content: "related memo 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relations + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo1.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo2.ID, + Type: store.MemoRelationComment, + }) + require.NoError(t, err) + + // List by memo ID + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Equal(t, 2, len(relations)) + + // List by type + refType := store.MemoRelationReference + refRelations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + Type: &refType, + }) + require.NoError(t, err) + require.Equal(t, 1, len(refRelations)) + require.Equal(t, store.MemoRelationReference, refRelations[0].Type) + + // List by related memo ID + relations, err = ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + RelatedMemoID: &relatedMemo1.ID, + }) + require.NoError(t, err) + require.Equal(t, 1, len(relations)) + + ts.Close() +} + +func TestMemoRelationDelete(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create memos + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo", + CreatorID: user.ID, + Content: "related memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relation + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Verify relation exists + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Equal(t, 1, len(relations)) + + // Delete relation by memo ID + relType := store.MemoRelationReference + err = ts.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ + MemoID: &mainMemo.ID, + RelatedMemoID: &relatedMemo.ID, + Type: &relType, + }) + require.NoError(t, err) + + // Verify relation is deleted + relations, err = ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Equal(t, 0, len(relations)) + + ts.Close() +} + +func TestMemoRelationDifferentTypes(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo", + CreatorID: user.ID, + Content: "related memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create reference relation + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Create comment relation (same memos, different type - should be allowed) + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationComment, + }) + require.NoError(t, err) + + // Verify both relations exist + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Equal(t, 2, len(relations)) + + ts.Close() +} diff --git a/store/test/memo_test.go b/store/test/memo_test.go index 3122136a1..bcc368a73 100644 --- a/store/test/memo_test.go +++ b/store/test/memo_test.go @@ -2,6 +2,7 @@ package test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/require" @@ -114,3 +115,294 @@ func TestDeleteMemoStore(t *testing.T) { require.NoError(t, err) ts.Close() } + +func TestMemoGetByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "test-memo-1", + CreatorID: user.ID, + Content: "test content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Get by ID + found, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, memo.ID, found.ID) + require.Equal(t, memo.Content, found.Content) + + // Get non-existent + nonExistentID := int32(99999) + notFound, err := ts.GetMemo(ctx, &store.FindMemo{ID: &nonExistentID}) + require.NoError(t, err) + require.Nil(t, notFound) + + ts.Close() +} + +func TestMemoGetByUID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + uid := "unique-memo-uid" + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: uid, + CreatorID: user.ID, + Content: "test content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Get by UID + found, err := ts.GetMemo(ctx, &store.FindMemo{UID: &uid}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, memo.UID, found.UID) + + // Get non-existent UID + nonExistentUID := "non-existent-uid" + notFound, err := ts.GetMemo(ctx, &store.FindMemo{UID: &nonExistentUID}) + require.NoError(t, err) + require.Nil(t, notFound) + + ts.Close() +} + +func TestMemoListByVisibility(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create memos with different visibilities + _, err = ts.CreateMemo(ctx, &store.Memo{ + UID: "public-memo", + CreatorID: user.ID, + Content: "public content", + Visibility: store.Public, + }) + require.NoError(t, err) + + _, err = ts.CreateMemo(ctx, &store.Memo{ + UID: "protected-memo", + CreatorID: user.ID, + Content: "protected content", + Visibility: store.Protected, + }) + require.NoError(t, err) + + _, err = ts.CreateMemo(ctx, &store.Memo{ + UID: "private-memo", + CreatorID: user.ID, + Content: "private content", + Visibility: store.Private, + }) + require.NoError(t, err) + + // List public memos only + publicMemos, err := ts.ListMemos(ctx, &store.FindMemo{ + VisibilityList: []store.Visibility{store.Public}, + }) + require.NoError(t, err) + require.Equal(t, 1, len(publicMemos)) + require.Equal(t, store.Public, publicMemos[0].Visibility) + + // List protected memos only + protectedMemos, err := ts.ListMemos(ctx, &store.FindMemo{ + VisibilityList: []store.Visibility{store.Protected}, + }) + require.NoError(t, err) + require.Equal(t, 1, len(protectedMemos)) + require.Equal(t, store.Protected, protectedMemos[0].Visibility) + + // List public and protected (multiple visibility) + publicAndProtected, err := ts.ListMemos(ctx, &store.FindMemo{ + VisibilityList: []store.Visibility{store.Public, store.Protected}, + }) + require.NoError(t, err) + require.Equal(t, 2, len(publicAndProtected)) + + // List all + allMemos, err := ts.ListMemos(ctx, &store.FindMemo{}) + require.NoError(t, err) + require.Equal(t, 3, len(allMemos)) + + ts.Close() +} + +func TestMemoListWithPagination(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create 10 memos + for i := 0; i < 10; i++ { + _, err := ts.CreateMemo(ctx, &store.Memo{ + UID: fmt.Sprintf("memo-%d", i), + CreatorID: user.ID, + Content: fmt.Sprintf("content %d", i), + Visibility: store.Public, + }) + require.NoError(t, err) + } + + // Test limit + limit := 5 + limitedMemos, err := ts.ListMemos(ctx, &store.FindMemo{Limit: &limit}) + require.NoError(t, err) + require.Equal(t, 5, len(limitedMemos)) + + // Test offset + offset := 3 + offsetMemos, err := ts.ListMemos(ctx, &store.FindMemo{Limit: &limit, Offset: &offset}) + require.NoError(t, err) + require.Equal(t, 5, len(offsetMemos)) + + // Verify offset works correctly (different memos) + require.NotEqual(t, limitedMemos[0].ID, offsetMemos[0].ID) + + ts.Close() +} + +func TestMemoUpdatePinned(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "pinnable-memo", + CreatorID: user.ID, + Content: "content", + Visibility: store.Public, + }) + require.NoError(t, err) + require.False(t, memo.Pinned) + + // Pin the memo + pinned := true + err = ts.UpdateMemo(ctx, &store.UpdateMemo{ + ID: memo.ID, + Pinned: &pinned, + }) + require.NoError(t, err) + + // Verify pinned + found, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) + require.NoError(t, err) + require.True(t, found.Pinned) + + // Unpin + unpinned := false + err = ts.UpdateMemo(ctx, &store.UpdateMemo{ + ID: memo.ID, + Pinned: &unpinned, + }) + require.NoError(t, err) + + found, err = ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) + require.NoError(t, err) + require.False(t, found.Pinned) + + ts.Close() +} + +func TestMemoUpdateVisibility(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "visibility-memo", + CreatorID: user.ID, + Content: "content", + Visibility: store.Public, + }) + require.NoError(t, err) + require.Equal(t, store.Public, memo.Visibility) + + // Change to private + privateVisibility := store.Private + err = ts.UpdateMemo(ctx, &store.UpdateMemo{ + ID: memo.ID, + Visibility: &privateVisibility, + }) + require.NoError(t, err) + + found, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) + require.NoError(t, err) + require.Equal(t, store.Private, found.Visibility) + + // Change to protected + protectedVisibility := store.Protected + err = ts.UpdateMemo(ctx, &store.UpdateMemo{ + ID: memo.ID, + Visibility: &protectedVisibility, + }) + require.NoError(t, err) + + found, err = ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) + require.NoError(t, err) + require.Equal(t, store.Protected, found.Visibility) + + ts.Close() +} + +func TestMemoInvalidUID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create memo with invalid UID (contains special characters) + _, err = ts.CreateMemo(ctx, &store.Memo{ + UID: "invalid uid with spaces", + CreatorID: user.ID, + Content: "content", + Visibility: store.Public, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid uid") + + ts.Close() +} + +func TestMemoWithPayload(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create memo with tags in payload + tags := []string{"tag1", "tag2", "tag3"} + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-with-payload", + CreatorID: user.ID, + Content: "content with tags", + Visibility: store.Public, + Payload: &storepb.MemoPayload{ + Tags: tags, + }, + }) + require.NoError(t, err) + require.NotNil(t, memo.Payload) + require.Equal(t, tags, memo.Payload.Tags) + + // Fetch and verify + found, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) + require.NoError(t, err) + require.NotNil(t, found.Payload) + require.Equal(t, tags, found.Payload.Tags) + + ts.Close() +} diff --git a/store/test/reaction_test.go b/store/test/reaction_test.go index dc0817547..986eed9b2 100644 --- a/store/test/reaction_test.go +++ b/store/test/reaction_test.go @@ -65,3 +65,121 @@ func TestReactionStore(t *testing.T) { ts.Close() } + +func TestReactionListByCreatorID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + user1, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser) + require.NoError(t, err) + + contentID := "shared_content" + + // User 1 creates reaction + _, err = ts.UpsertReaction(ctx, &store.Reaction{ + CreatorID: user1.ID, + ContentID: contentID, + ReactionType: "πŸ‘", + }) + require.NoError(t, err) + + // User 2 creates reaction + _, err = ts.UpsertReaction(ctx, &store.Reaction{ + CreatorID: user2.ID, + ContentID: contentID, + ReactionType: "❀️", + }) + require.NoError(t, err) + + // List all reactions for content + reactions, err := ts.ListReactions(ctx, &store.FindReaction{ + ContentID: &contentID, + }) + require.NoError(t, err) + require.Len(t, reactions, 2) + + // List by creator ID + user1Reactions, err := ts.ListReactions(ctx, &store.FindReaction{ + CreatorID: &user1.ID, + }) + require.NoError(t, err) + require.Len(t, user1Reactions, 1) + require.Equal(t, "πŸ‘", user1Reactions[0].ReactionType) + + ts.Close() +} + +func TestReactionMultipleContentIDs(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + contentID1 := "content_1" + contentID2 := "content_2" + + // Create reactions for different contents + _, err = ts.UpsertReaction(ctx, &store.Reaction{ + CreatorID: user.ID, + ContentID: contentID1, + ReactionType: "πŸ‘", + }) + require.NoError(t, err) + + _, err = ts.UpsertReaction(ctx, &store.Reaction{ + CreatorID: user.ID, + ContentID: contentID2, + ReactionType: "❀️", + }) + require.NoError(t, err) + + // List by content ID list + reactions, err := ts.ListReactions(ctx, &store.FindReaction{ + ContentIDList: []string{contentID1, contentID2}, + }) + require.NoError(t, err) + require.Len(t, reactions, 2) + + ts.Close() +} + +func TestReactionUpsertDifferentTypes(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + contentID := "test_content" + + // Create first reaction + reaction1, err := ts.UpsertReaction(ctx, &store.Reaction{ + CreatorID: user.ID, + ContentID: contentID, + ReactionType: "πŸ‘", + }) + require.NoError(t, err) + + // Create second reaction with different type (should create new, not update) + reaction2, err := ts.UpsertReaction(ctx, &store.Reaction{ + CreatorID: user.ID, + ContentID: contentID, + ReactionType: "❀️", + }) + require.NoError(t, err) + + // Both reactions should exist + require.NotEqual(t, reaction1.ID, reaction2.ID) + + reactions, err := ts.ListReactions(ctx, &store.FindReaction{ + ContentID: &contentID, + }) + require.NoError(t, err) + require.Len(t, reactions, 2) + + ts.Close() +} diff --git a/store/test/store.go b/store/test/store.go index 84371bc2b..3c7abf20a 100644 --- a/store/test/store.go +++ b/store/test/store.go @@ -3,7 +3,6 @@ package test import ( "context" "fmt" - "log/slog" "net" "os" "testing" @@ -19,63 +18,25 @@ import ( "github.com/usememos/memos/store/db" ) +// NewTestingStore creates a new testing store with a fresh database. +// Each test gets its own isolated database: +// - SQLite: new temp file per test +// - MySQL/PostgreSQL: new database per test in shared container func NewTestingStore(ctx context.Context, t *testing.T) *store.Store { - profile := getTestingProfile(t) + driver := getDriverFromEnv() + profile := getTestingProfileForDriver(t, driver) dbDriver, err := db.NewDBDriver(profile) if err != nil { - slog.Error("failed to create db driver", slog.String("error", err.Error())) + t.Fatalf("failed to create db driver: %v", err) } - resetTestingDB(ctx, profile, dbDriver) store := store.New(dbDriver, profile) if err := store.Migrate(ctx); err != nil { - slog.Error("failed to migrate db", slog.String("error", err.Error())) + t.Fatalf("failed to migrate db: %v", err) } return store } -func resetTestingDB(ctx context.Context, profile *profile.Profile, dbDriver store.Driver) { - if profile.Driver == "mysql" { - _, err := dbDriver.GetDB().ExecContext(ctx, ` - DROP TABLE IF EXISTS system_setting; - DROP TABLE IF EXISTS user; - DROP TABLE IF EXISTS user_setting; - DROP TABLE IF EXISTS memo; - DROP TABLE IF EXISTS memo_organizer; - DROP TABLE IF EXISTS memo_relation; - DROP TABLE IF EXISTS resource; - DROP TABLE IF EXISTS tag; - DROP TABLE IF EXISTS activity; - DROP TABLE IF EXISTS storage; - DROP TABLE IF EXISTS idp; - DROP TABLE IF EXISTS inbox; - DROP TABLE IF EXISTS reaction;`) - if err != nil { - slog.Error("failed to reset testing db", slog.String("error", err.Error())) - panic(err) - } - } else if profile.Driver == "postgres" { - _, err := dbDriver.GetDB().ExecContext(ctx, ` - DROP TABLE IF EXISTS system_setting CASCADE; - DROP TABLE IF EXISTS "user" CASCADE; - DROP TABLE IF EXISTS user_setting CASCADE; - DROP TABLE IF EXISTS memo CASCADE; - DROP TABLE IF EXISTS memo_organizer CASCADE; - DROP TABLE IF EXISTS memo_relation CASCADE; - DROP TABLE IF EXISTS resource CASCADE; - DROP TABLE IF EXISTS tag CASCADE; - DROP TABLE IF EXISTS activity CASCADE; - DROP TABLE IF EXISTS storage CASCADE; - DROP TABLE IF EXISTS idp CASCADE; - DROP TABLE IF EXISTS inbox CASCADE; - DROP TABLE IF EXISTS reaction CASCADE;`) - if err != nil { - slog.Error("failed to reset testing db", slog.String("error", err.Error())) - panic(err) - } - } -} - func getUnusedPort() int { // Get a random unused port listener, err := net.Listen("tcp", "localhost:0") @@ -89,20 +50,28 @@ func getUnusedPort() int { return port } -func getTestingProfile(t *testing.T) *profile.Profile { - if err := godotenv.Load(".env"); err != nil { - t.Log("failed to load .env file, but it's ok") - } +// getTestingProfileForDriver creates a testing profile for a specific driver. +func getTestingProfileForDriver(t *testing.T, driver string) *profile.Profile { + // Attempt to load .env file if present (optional, for local development) + _ = godotenv.Load(".env") // Get a temporary directory for the test data. dir := t.TempDir() mode := "prod" port := getUnusedPort() - driver := getDriverFromEnv() - dsn := os.Getenv("DSN") - if driver == "sqlite" { + + var dsn string + switch driver { + case "sqlite": dsn = fmt.Sprintf("%s/memos_%s.db", dir, mode) + case "mysql": + dsn = GetMySQLDSN(t) + case "postgres": + dsn = GetPostgresDSN(t) + default: + t.Fatalf("unsupported driver: %s", driver) } + return &profile.Profile{ Mode: mode, Port: port, diff --git a/store/test/user_setting_test.go b/store/test/user_setting_test.go index 47ff170b8..99a40f775 100644 --- a/store/test/user_setting_test.go +++ b/store/test/user_setting_test.go @@ -26,3 +26,285 @@ func TestUserSettingStore(t *testing.T) { require.Equal(t, 1, len(list)) ts.Close() } + +func TestUserSettingGetByUserID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create setting + _, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: user.ID, + Key: storepb.UserSetting_GENERAL, + Value: &storepb.UserSetting_General{General: &storepb.GeneralUserSetting{Locale: "zh"}}, + }) + require.NoError(t, err) + + // Get by user ID + setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{ + UserID: &user.ID, + Key: storepb.UserSetting_GENERAL, + }) + require.NoError(t, err) + require.NotNil(t, setting) + require.Equal(t, "zh", setting.GetGeneral().Locale) + + // Get non-existent key + nonExistentSetting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{ + UserID: &user.ID, + Key: storepb.UserSetting_SHORTCUTS, + }) + require.NoError(t, err) + require.Nil(t, nonExistentSetting) + + ts.Close() +} + +func TestUserSettingUpsertUpdate(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create initial setting + _, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: user.ID, + Key: storepb.UserSetting_GENERAL, + Value: &storepb.UserSetting_General{General: &storepb.GeneralUserSetting{Locale: "en"}}, + }) + require.NoError(t, err) + + // Update setting + _, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: user.ID, + Key: storepb.UserSetting_GENERAL, + Value: &storepb.UserSetting_General{General: &storepb.GeneralUserSetting{Locale: "fr"}}, + }) + require.NoError(t, err) + + // Verify update + setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{ + UserID: &user.ID, + Key: storepb.UserSetting_GENERAL, + }) + require.NoError(t, err) + require.Equal(t, "fr", setting.GetGeneral().Locale) + + // Verify only one setting exists + list, err := ts.ListUserSettings(ctx, &store.FindUserSetting{UserID: &user.ID}) + require.NoError(t, err) + require.Equal(t, 1, len(list)) + + ts.Close() +} + +func TestUserSettingRefreshTokens(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Initially no tokens + tokens, err := ts.GetUserRefreshTokens(ctx, user.ID) + require.NoError(t, err) + require.Empty(t, tokens) + + // Add a refresh token + token1 := &storepb.RefreshTokensUserSetting_RefreshToken{ + TokenId: "token-1", + Description: "Chrome browser session", + } + err = ts.AddUserRefreshToken(ctx, user.ID, token1) + require.NoError(t, err) + + // Verify token was added + tokens, err = ts.GetUserRefreshTokens(ctx, user.ID) + require.NoError(t, err) + require.Len(t, tokens, 1) + require.Equal(t, "token-1", tokens[0].TokenId) + + // Add another token + token2 := &storepb.RefreshTokensUserSetting_RefreshToken{ + TokenId: "token-2", + Description: "Firefox browser session", + } + err = ts.AddUserRefreshToken(ctx, user.ID, token2) + require.NoError(t, err) + + tokens, err = ts.GetUserRefreshTokens(ctx, user.ID) + require.NoError(t, err) + require.Len(t, tokens, 2) + + // Get specific token by ID + foundToken, err := ts.GetUserRefreshTokenByID(ctx, user.ID, "token-1") + require.NoError(t, err) + require.NotNil(t, foundToken) + require.Equal(t, "Chrome browser session", foundToken.Description) + + // Get non-existent token + notFound, err := ts.GetUserRefreshTokenByID(ctx, user.ID, "non-existent") + require.NoError(t, err) + require.Nil(t, notFound) + + // Remove token + err = ts.RemoveUserRefreshToken(ctx, user.ID, "token-1") + require.NoError(t, err) + + tokens, err = ts.GetUserRefreshTokens(ctx, user.ID) + require.NoError(t, err) + require.Len(t, tokens, 1) + require.Equal(t, "token-2", tokens[0].TokenId) + + ts.Close() +} + +func TestUserSettingPersonalAccessTokens(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Initially no PATs + pats, err := ts.GetUserPersonalAccessTokens(ctx, user.ID) + require.NoError(t, err) + require.Empty(t, pats) + + // Add a PAT + pat1 := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-1", + TokenHash: "pat-hash-1", + Description: "API Token for external access", + } + err = ts.AddUserPersonalAccessToken(ctx, user.ID, pat1) + require.NoError(t, err) + + // Verify PAT was added + pats, err = ts.GetUserPersonalAccessTokens(ctx, user.ID) + require.NoError(t, err) + require.Len(t, pats, 1) + require.Equal(t, "API Token for external access", pats[0].Description) + + // Add another PAT + pat2 := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-2", + TokenHash: "pat-hash-2", + Description: "CI Token", + } + err = ts.AddUserPersonalAccessToken(ctx, user.ID, pat2) + require.NoError(t, err) + + pats, err = ts.GetUserPersonalAccessTokens(ctx, user.ID) + require.NoError(t, err) + require.Len(t, pats, 2) + + // Remove PAT + err = ts.RemoveUserPersonalAccessToken(ctx, user.ID, "pat-1") + require.NoError(t, err) + + pats, err = ts.GetUserPersonalAccessTokens(ctx, user.ID) + require.NoError(t, err) + require.Len(t, pats, 1) + require.Equal(t, "pat-2", pats[0].TokenId) + + ts.Close() +} + +func TestUserSettingWebhooks(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Initially no webhooks + webhooks, err := ts.GetUserWebhooks(ctx, user.ID) + require.NoError(t, err) + require.Empty(t, webhooks) + + // Add a webhook + webhook1 := &storepb.WebhooksUserSetting_Webhook{ + Id: "webhook-1", + Title: "Deploy Hook", + Url: "https://example.com/webhook", + } + err = ts.AddUserWebhook(ctx, user.ID, webhook1) + require.NoError(t, err) + + // Verify webhook was added + webhooks, err = ts.GetUserWebhooks(ctx, user.ID) + require.NoError(t, err) + require.Len(t, webhooks, 1) + require.Equal(t, "Deploy Hook", webhooks[0].Title) + + // Update webhook + webhook1Updated := &storepb.WebhooksUserSetting_Webhook{ + Id: "webhook-1", + Title: "Updated Deploy Hook", + Url: "https://example.com/webhook/v2", + } + err = ts.UpdateUserWebhook(ctx, user.ID, webhook1Updated) + require.NoError(t, err) + + webhooks, err = ts.GetUserWebhooks(ctx, user.ID) + require.NoError(t, err) + require.Len(t, webhooks, 1) + require.Equal(t, "Updated Deploy Hook", webhooks[0].Title) + require.Equal(t, "https://example.com/webhook/v2", webhooks[0].Url) + + // Add another webhook + webhook2 := &storepb.WebhooksUserSetting_Webhook{ + Id: "webhook-2", + Title: "Notification Hook", + Url: "https://slack.example.com/webhook", + } + err = ts.AddUserWebhook(ctx, user.ID, webhook2) + require.NoError(t, err) + + webhooks, err = ts.GetUserWebhooks(ctx, user.ID) + require.NoError(t, err) + require.Len(t, webhooks, 2) + + // Remove webhook + err = ts.RemoveUserWebhook(ctx, user.ID, "webhook-1") + require.NoError(t, err) + + webhooks, err = ts.GetUserWebhooks(ctx, user.ID) + require.NoError(t, err) + require.Len(t, webhooks, 1) + require.Equal(t, "webhook-2", webhooks[0].Id) + + ts.Close() +} + +func TestUserSettingShortcuts(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create shortcuts setting + shortcuts := &storepb.ShortcutsUserSetting{ + Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{ + {Id: "shortcut-1", Title: "Work Notes", Filter: "tag:work"}, + {Id: "shortcut-2", Title: "Personal", Filter: "tag:personal"}, + }, + } + _, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: user.ID, + Key: storepb.UserSetting_SHORTCUTS, + Value: &storepb.UserSetting_Shortcuts{Shortcuts: shortcuts}, + }) + require.NoError(t, err) + + // Retrieve and verify + setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{ + UserID: &user.ID, + Key: storepb.UserSetting_SHORTCUTS, + }) + require.NoError(t, err) + require.NotNil(t, setting) + require.Len(t, setting.GetShortcuts().Shortcuts, 2) + require.Equal(t, "Work Notes", setting.GetShortcuts().Shortcuts[0].Title) + + ts.Close() +} diff --git a/store/test/user_test.go b/store/test/user_test.go index 3f336f2f3..2ffa202fd 100644 --- a/store/test/user_test.go +++ b/store/test/user_test.go @@ -2,6 +2,7 @@ package test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/require" @@ -38,13 +39,213 @@ func TestUserStore(t *testing.T) { ts.Close() } +func TestUserGetByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Get user by ID + found, err := ts.GetUser(ctx, &store.FindUser{ID: &user.ID}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, user.ID, found.ID) + require.Equal(t, user.Username, found.Username) + + // Get non-existent user + nonExistentID := int32(99999) + notFound, err := ts.GetUser(ctx, &store.FindUser{ID: &nonExistentID}) + require.NoError(t, err) + require.Nil(t, notFound) + + // Get system bot + systemBotID := store.SystemBotID + systemBot, err := ts.GetUser(ctx, &store.FindUser{ID: &systemBotID}) + require.NoError(t, err) + require.NotNil(t, systemBot) + require.Equal(t, store.SystemBotID, systemBot.ID) + require.Equal(t, "system_bot", systemBot.Username) + + ts.Close() +} + +func TestUserGetByUsername(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Get user by username + found, err := ts.GetUser(ctx, &store.FindUser{Username: &user.Username}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, user.Username, found.Username) + + // Get non-existent username + nonExistent := "nonexistent" + notFound, err := ts.GetUser(ctx, &store.FindUser{Username: &nonExistent}) + require.NoError(t, err) + require.Nil(t, notFound) + + ts.Close() +} + +func TestUserListByRole(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create users with different roles + _, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + adminUser, err := createTestingUserWithRole(ctx, ts, "admin_user", store.RoleAdmin) + require.NoError(t, err) + + regularUser, err := createTestingUserWithRole(ctx, ts, "regular_user", store.RoleUser) + require.NoError(t, err) + + // List all users + allUsers, err := ts.ListUsers(ctx, &store.FindUser{}) + require.NoError(t, err) + require.Equal(t, 3, len(allUsers)) + + // List only HOST users + hostRole := store.RoleHost + hostUsers, err := ts.ListUsers(ctx, &store.FindUser{Role: &hostRole}) + require.NoError(t, err) + require.Equal(t, 1, len(hostUsers)) + require.Equal(t, store.RoleHost, hostUsers[0].Role) + + // List only ADMIN users + adminRole := store.RoleAdmin + adminUsers, err := ts.ListUsers(ctx, &store.FindUser{Role: &adminRole}) + require.NoError(t, err) + require.Equal(t, 1, len(adminUsers)) + require.Equal(t, adminUser.ID, adminUsers[0].ID) + + // List only USER role users + userRole := store.RoleUser + regularUsers, err := ts.ListUsers(ctx, &store.FindUser{Role: &userRole}) + require.NoError(t, err) + require.Equal(t, 1, len(regularUsers)) + require.Equal(t, regularUser.ID, regularUsers[0].ID) + + ts.Close() +} + +func TestUserUpdateRowStatus(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + require.Equal(t, store.Normal, user.RowStatus) + + // Archive user + archivedStatus := store.Archived + updated, err := ts.UpdateUser(ctx, &store.UpdateUser{ + ID: user.ID, + RowStatus: &archivedStatus, + }) + require.NoError(t, err) + require.Equal(t, store.Archived, updated.RowStatus) + + // Verify by fetching + fetched, err := ts.GetUser(ctx, &store.FindUser{ID: &user.ID}) + require.NoError(t, err) + require.Equal(t, store.Archived, fetched.RowStatus) + + // Restore to normal + normalStatus := store.Normal + restored, err := ts.UpdateUser(ctx, &store.UpdateUser{ + ID: user.ID, + RowStatus: &normalStatus, + }) + require.NoError(t, err) + require.Equal(t, store.Normal, restored.RowStatus) + + ts.Close() +} + +func TestUserUpdateAllFields(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Update all fields + newUsername := "updated_username" + newEmail := "updated@test.com" + newNickname := "Updated Nickname" + newAvatarURL := "https://example.com/avatar.png" + newDescription := "Updated description" + newRole := store.RoleAdmin + newPasswordHash := "new_password_hash" + + updated, err := ts.UpdateUser(ctx, &store.UpdateUser{ + ID: user.ID, + Username: &newUsername, + Email: &newEmail, + Nickname: &newNickname, + AvatarURL: &newAvatarURL, + Description: &newDescription, + Role: &newRole, + PasswordHash: &newPasswordHash, + }) + require.NoError(t, err) + require.Equal(t, newUsername, updated.Username) + require.Equal(t, newEmail, updated.Email) + require.Equal(t, newNickname, updated.Nickname) + require.Equal(t, newAvatarURL, updated.AvatarURL) + require.Equal(t, newDescription, updated.Description) + require.Equal(t, newRole, updated.Role) + require.Equal(t, newPasswordHash, updated.PasswordHash) + + // Verify by fetching again + fetched, err := ts.GetUser(ctx, &store.FindUser{ID: &user.ID}) + require.NoError(t, err) + require.Equal(t, newUsername, fetched.Username) + + ts.Close() +} + +func TestUserListWithLimit(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create 5 users + for i := 0; i < 5; i++ { + role := store.RoleUser + if i == 0 { + role = store.RoleHost + } + _, err := createTestingUserWithRole(ctx, ts, fmt.Sprintf("user%d", i), role) + require.NoError(t, err) + } + + // List with limit + limit := 3 + users, err := ts.ListUsers(ctx, &store.FindUser{Limit: &limit}) + require.NoError(t, err) + require.Equal(t, 3, len(users)) + + ts.Close() +} + func createTestingHostUser(ctx context.Context, ts *store.Store) (*store.User, error) { + return createTestingUserWithRole(ctx, ts, "test", store.RoleHost) +} + +func createTestingUserWithRole(ctx context.Context, ts *store.Store, username string, role store.Role) (*store.User, error) { userCreate := &store.User{ - Username: "test", - Role: store.RoleHost, - Email: "test@test.com", - Nickname: "test_nickname", - Description: "test_description", + Username: username, + Role: role, + Email: username + "@test.com", + Nickname: username + "_nickname", + Description: username + "_description", } passwordHash, err := bcrypt.GenerateFromPassword([]byte("test_password"), bcrypt.DefaultCost) if err != nil { diff --git a/web/src/components/LeafletMap.tsx b/web/src/components/LeafletMap.tsx index cd2fd66a2..648b42ed5 100644 --- a/web/src/components/LeafletMap.tsx +++ b/web/src/components/LeafletMap.tsx @@ -34,7 +34,7 @@ const LocationMarker = (props: MarkerProps) => { // Call the parent onChange function. props.onChange(e.latlng); }, - locationfound() { }, + locationfound() {}, }); useEffect(() => { @@ -246,7 +246,7 @@ const LeafletMap = (props: MapProps) => { isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" } /> - { }} /> + {}} />